Files
OpsLog/internal/extsvc/extsvc.go
T
rouggy 784d809290 fix(rotator): put the SP/LP readout inside the compass itself
It went under the Station Control rotator; the compass that wanted it is the
docked one beside the entry strip. Both are the same RotorCompass, so the
readout moves INTO that component: every compass in the app carries it, and no
caller draws its own.

The bezel already showed the short path as a red marker, but a marker is a
direction, not a number — and the numbers in the status bar are 10px, which is
what prompted this.

Clickable only when the caller passed onGoto, since a compass rendered without
one cannot turn anything and a button that does nothing is worse than a label.
2026-08-13 11:45:49 +02:00

154 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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 12 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"`
// OnAir: HRDLog only — publish the live frequency, mode and rig on the site.
OnAir bool `json:"on_air"`
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)
// Ignored is set when the service accepted the submission but left some or
// all of the QSOs out of it. TQSL does this for contacts already uploaded
// AND for contacts outside the certificate's date range, and reports both
// with the same exit code — so the caller must show the message rather than
// decide on its own that everything went through.
Ignored bool
}