feat: upload to external services clublog qrz

This commit is contained in:
2026-05-28 22:52:50 +02:00
parent e82e30dd02
commit 5c004f5e2f
26 changed files with 1710 additions and 31 deletions
+123
View File
@@ -0,0 +1,123 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
// rather than asking each user for one. It's an application identifier, not
// a user secret, but note it is visible in the source and the binary.
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
// UploadClublog pushes one ADIF record to Club Log in real time. The user
// supplies the account email + password and the logbook callsign; the
// application API key is embedded (clublogAppAPIKey), so users never need
// one — same UX as Log4OM.
//
// Form params:
//
// email, password, callsign, adif, clientident, api
//
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
// status with a plain-text reason in the body.
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifRecord) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("adif", adifRecord)
form.Set("clientident", "OpsLog")
// Club Log requires the application API key. Use OpsLog's embedded key;
// a per-user override (cfg.APIKey) wins if one is ever configured.
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
if err != nil {
return UploadResult{}, err
}
return res, nil
}
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
_ = ctx
switch {
case strings.TrimSpace(cfg.Email) == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case strings.TrimSpace(cfg.Callsign) == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
}
// clublogPost performs the form POST and maps the HTTP status to a result.
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
switch {
case resp.StatusCode == http.StatusOK:
return UploadResult{OK: true, Message: msg}, nil
case isClublogDuplicate(resp.StatusCode, msg):
// Club Log rejects an exact duplicate; treat as already-logged.
return UploadResult{OK: true, Message: "already in logbook"}, nil
default:
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
}
}
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
// so repeated uploads stay idempotent.
func isClublogDuplicate(status int, msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
}
+90
View File
@@ -0,0 +1,90 @@
// Package extsvc uploads logged QSOs to external logbook services
// (QRZ.com first; Clublog and LoTW to follow). Each service has its own
// credentials and an upload mode chosen per-service: "immediate" pushes as
// soon as the QSO is saved, "delayed" waits a random 12 minutes (like
// Log4OM) so a mistakenly-logged QSO can still be edited or removed before
// it leaves.
//
// The Manager is intentionally fire-and-forget: a failed or skipped upload
// just leaves the QSO's per-service upload-status column empty, and the
// (future) manual-upload window will let the user retry the backlog.
package extsvc
import (
"errors"
"strings"
)
// errFromResult turns a non-OK result with no transport error into one
// (defensive — uploaders normally return an error alongside !OK).
func errFromResult(r UploadResult) error {
if r.Message != "" {
return errors.New(r.Message)
}
return errors.New("upload rejected")
}
// Service identifies one external logbook.
type Service string
const (
ServiceQRZ Service = "qrz" // QRZ.com Logbook
ServiceClublog Service = "clublog" // Club Log real-time upload
// ServiceLoTW to come.
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
type UploadMode string
const (
// ModeImmediate uploads as soon as the QSO is logged.
ModeImmediate UploadMode = "immediate"
// ModeDelayed waits a random 12 minutes before uploading.
ModeDelayed UploadMode = "delayed"
)
// ServiceConfig is the per-service user configuration. It's a superset of
// the credential shapes the different services need — each service reads
// only the fields it uses:
//
// QRZ.com → APIKey, ForceStationCallsign
// Club Log → Email, Password, Callsign, APIKey
//
// AutoUpload + UploadMode are common to all (timing is per-service, so the
// user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct {
APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"`
}
// normalised returns the config with whitespace trimmed and a valid upload
// mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
if c.UploadMode != ModeDelayed {
c.UploadMode = ModeImmediate
}
return c
}
// ExternalServices bundles every service's config for the settings UI.
// LoTW fields will be added as that service lands.
type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
}
// UploadResult is the outcome of a single upload attempt.
type UploadResult struct {
OK bool // the service accepted (or already had) the QSO
LogID string // service-assigned record id, when provided
Message string // human-readable detail (reason on failure)
}
+160
View File
@@ -0,0 +1,160 @@
package extsvc
import (
"context"
"math/rand"
"net/http"
"sync"
"time"
)
// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
type Deps struct {
Client *http.Client
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
// overridden by forceCall when non-empty. ok=false means "skip silently"
// (row gone, missing required fields, …).
BuildADIF func(id int64, forceCall string) (record string, ok bool)
// MarkUploaded stamps the per-service upload status on the QSO row and
// notifies the UI. Called once, on success.
MarkUploaded func(svc Service, id int64, logID string)
// NotifyError surfaces a failed upload (logging + optional UI event).
NotifyError func(svc Service, id int64, err error)
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
// Manager owns the external-service config snapshot and schedules uploads
// when a QSO is logged. Immediate uploads run in their own goroutine;
// delayed uploads use a timer with a random 12 minute fuse.
type Manager struct {
deps Deps
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
}
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
}
return &Manager{
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (m *Manager) logf(format string, args ...any) {
if m.deps.Logf != nil {
m.deps.Logf(format, args...)
}
}
// SetConfig replaces the active config snapshot (called after the user
// saves the External Services settings).
func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock()
defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised()
m.cfg = cfg
}
// Config returns the current snapshot.
func (m *Manager) Config() ExternalServices {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// delaySeconds returns a random 60120s fuse for delayed uploads.
func (m *Manager) delaySeconds() time.Duration {
m.mu.Lock()
d := 60 + m.rnd.Intn(61) // [60, 120]
m.mu.Unlock()
return time.Duration(d) * time.Second
}
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
// auto-log). It fans out to every enabled, auto-upload service in the
// configured timing mode. Returns immediately.
func (m *Manager) OnQSOLogged(id int64) {
cfg := m.Config()
// QRZ.com
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
m.scheduleUpload(ServiceQRZ, id, qrz)
}
// Club Log — email + password + callsign are enough (no API key).
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
m.scheduleUpload(ServiceClublog, id, cl)
}
// LoTW will be added here.
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeDelayed {
d := m.delaySeconds()
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
return
}
go m.upload(svc, id, cfg)
}
// upload performs the actual push. It builds a fresh, lifecycle-independent
// context so a delayed upload still completes even if it fires close to
// shutdown.
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var res UploadResult
var err error
switch svc {
case ServiceQRZ:
// QRZ rewrites STATION_CALLSIGN to the registered call.
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
// Club Log takes the logbook callsign as a separate param, so the
// ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
default:
return
}
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
}
+157
View File
@@ -0,0 +1,157 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// qrzAPIURL is the QRZ.com Logbook API endpoint. Note this is the LOGBOOK
// API (key from the logbook's settings page), NOT the XML lookup
// subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api"
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
// treat as success so retries are idempotent).
//
// API shape (form-encoded POST):
//
// KEY=<logbook key>&ACTION=INSERT&ADIF=<one record>&OPTION=
//
// Response is URL-encoded key/values, e.g.:
//
// STATUS=OK&LOGID=123456&COUNT=1&...
// STATUS=FAIL&REASON=Unable+to+add+QSO+...&...
// STATUS=AUTH&REASON=invalid+api+key&...
func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord string) (UploadResult, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return UploadResult{}, fmt.Errorf("qrz: api key not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("qrz: empty adif record")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "INSERT")
// OPTION=REPLACE would overwrite an existing matching QSO; we leave it
// empty so QRZ rejects duplicates (which we map to OK below).
form.Set("ADIF", adifRecord)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return parseQRZResponse(string(body))
}
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
// human-readable summary (callsign + QSO count) for the settings UI. An
// invalid key comes back as STATUS=AUTH → returned as an error.
func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "STATUS")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
if status == "AUTH" || status == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" {
reason = "invalid API key"
}
return "", fmt.Errorf("qrz: %s", reason)
}
call := strings.TrimSpace(vals.Get("CALLSIGN"))
count := strings.TrimSpace(vals.Get("COUNT"))
switch {
case call != "" && count != "":
return fmt.Sprintf("Connected — %s logbook, %s QSOs", call, count), nil
case call != "":
return fmt.Sprintf("Connected — %s logbook", call), nil
default:
return "Connected — key OK", nil
}
}
// parseQRZResponse decodes QRZ's "&"-joined, URL-encoded reply.
func parseQRZResponse(body string) (UploadResult, error) {
vals, err := url.ParseQuery(strings.TrimSpace(body))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
reason := strings.TrimSpace(vals.Get("REASON"))
logID := strings.TrimSpace(vals.Get("LOGID"))
switch status {
case "OK":
return UploadResult{OK: true, LogID: logID, Message: reason}, nil
case "FAIL":
// A duplicate is a benign failure — the QSO is in the logbook, so
// from our side the upload "succeeded". Detect it from the reason.
if isDuplicateReason(reason) {
return UploadResult{OK: true, LogID: logID, Message: "already in logbook"}, nil
}
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: upload failed: %s", reason)
case "AUTH":
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: auth error: %s", reason)
default:
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: unexpected status %q (%s)", status, reason)
}
}
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
// already present.
func isDuplicateReason(reason string) bool {
r := strings.ToLower(reason)
return strings.Contains(r, "duplicate") ||
strings.Contains(r, "already") ||
strings.Contains(r, "unable to add") && strings.Contains(r, "exists")
}
+33
View File
@@ -0,0 +1,33 @@
package extsvc
import "testing"
func TestParseQRZResponse(t *testing.T) {
cases := []struct {
name string
body string
wantOK bool
wantErr bool
logID string
}{
{"insert ok", "STATUS=OK&LOGID=123456&COUNT=1", true, false, "123456"},
{"duplicate is ok", "STATUS=FAIL&REASON=Unable+to+add+QSO+duplicate", true, false, ""},
{"already present", "STATUS=FAIL&REASON=QSO+already+in+logbook", true, false, ""},
{"real failure", "STATUS=FAIL&REASON=Bad+ADIF", false, true, ""},
{"auth failure", "STATUS=AUTH&REASON=invalid+api+key", false, true, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, err := parseQRZResponse(c.body)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if res.OK != c.wantOK {
t.Errorf("OK = %v, want %v", res.OK, c.wantOK)
}
if c.logID != "" && res.LogID != c.logID {
t.Errorf("LogID = %q, want %q", res.LogID, c.logID)
}
})
}
}