Opt-in, off by default: neither service can undo it, and a local delete is not
always a statement about the world — a duplicate cleared out of a contest log
was never meant to change the DX's log.
The two APIs are not alike, and the difference decides what is possible:
- Club Log deletes by MATCH (callsign, exact timestamp, band number), so it
works for every QSO in the log, past or future. The band table is its own
list and is pinned by a test: a band mapped to the wrong number does not
fail, it points the delete at a different QSO.
- QRZ.com deletes ONLY by logid. So markExtUploaded now stores the LOGID it
already received on every upload — it was being logged and thrown away. A
QSO uploaded before this cannot be withdrawn from here at all, and the log
says so by name rather than leaving the operator to assume it worked.
Both run BEFORE the local delete, because both need the QSO's own fields to
find their copy and there is nothing left to ask with once the row is gone.
Neither can block it: the operator asked for the QSO to go, and a refusing
website is not a reason to keep it.
146 lines
6.6 KiB
Go
146 lines
6.6 KiB
Go
// 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 1–2 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 Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||
// only the instance URL differs, so one service handles both.
|
||
ServiceCloudlog Service = "cloudlog"
|
||
)
|
||
|
||
// 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 1–2 minutes before uploading.
|
||
ModeDelayed UploadMode = "delayed"
|
||
// ModeOnClose queues QSOs and uploads them in one batch when the app
|
||
// closes. This is the LoTW-friendly mode (ARRL discourages per-QSO
|
||
// uploads), and it lets the user fix the whole session before sending.
|
||
ModeOnClose UploadMode = "on_close"
|
||
)
|
||
|
||
// 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
|
||
// LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
|
||
// (signs+uploads via TQSL; ForceStationCallsign overrides
|
||
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
|
||
//
|
||
// 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"`
|
||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||
Email string `json:"email"` // Club Log account email
|
||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||
Password string `json:"password"` // Club Log account / LoTW website password
|
||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||
Code string `json:"code"` // HRDLog: account upload code
|
||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||
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.URL = strings.TrimSpace(c.URL)
|
||
c.StationID = strings.TrimSpace(c.StationID)
|
||
c.Email = strings.TrimSpace(c.Email)
|
||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||
c.Code = strings.TrimSpace(c.Code)
|
||
c.Username = strings.TrimSpace(c.Username)
|
||
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
|
||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||
// here (the caller injects N+R when nothing is configured).
|
||
var flags []string
|
||
seen := map[string]bool{}
|
||
for _, f := range c.UploadFlags {
|
||
f = strings.ToUpper(strings.TrimSpace(f))
|
||
if (f == "N" || f == "R") && !seen[f] {
|
||
seen[f] = true
|
||
flags = append(flags, f)
|
||
}
|
||
}
|
||
c.UploadFlags = flags
|
||
switch c.UploadMode {
|
||
case ModeDelayed, ModeOnClose:
|
||
// keep
|
||
default:
|
||
c.UploadMode = ModeImmediate
|
||
}
|
||
return c
|
||
}
|
||
|
||
// ExternalServices bundles every service's config for the settings UI.
|
||
type ExternalServices struct {
|
||
QRZ ServiceConfig `json:"qrz"`
|
||
Clublog ServiceConfig `json:"clublog"`
|
||
LoTW ServiceConfig `json:"lotw"`
|
||
HRDLog ServiceConfig `json:"hrdlog"`
|
||
EQSL ServiceConfig `json:"eqsl"`
|
||
Cloudlog ServiceConfig `json:"cloudlog"`
|
||
|
||
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
|
||
// it is deleted locally. Off unless the operator turns it on: neither
|
||
// service can undo it, and a local delete is not always meant to reach the
|
||
// world — a duplicate cleared from a contest log was never meant to be a
|
||
// statement about the DX's log.
|
||
DeleteRemote bool `json:"delete_remote"`
|
||
}
|
||
|
||
// 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)
|
||
}
|