diff --git a/app.go b/app.go index 0aa73b1..ec35dea 100644 --- a/app.go +++ b/app.go @@ -19,6 +19,7 @@ import ( "hamlog/internal/cat" "hamlog/internal/cluster" "hamlog/internal/db" + "hamlog/internal/extsvc" "hamlog/internal/integrations/udp" "hamlog/internal/operating" "hamlog/internal/dxcc" @@ -83,6 +84,21 @@ const ( keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd" keyQSLDefaultClublogStatus = "qsl.clublog_status" keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status" + keyQSLDefaultQRZComStatus = "qsl.qrzcom_status" + + // External services (logbook upload). QRZ.com first; Clublog / LoTW + // will add their own keys under the same extsvc.* prefix. + keyExtQRZAPIKey = "extsvc.qrz.api_key" + keyExtQRZForceCall = "extsvc.qrz.force_station_callsign" + keyExtQRZAutoUpload = "extsvc.qrz.auto_upload" + keyExtQRZUploadMode = "extsvc.qrz.upload_mode" + + keyExtClublogEmail = "extsvc.clublog.email" + keyExtClublogPassword = "extsvc.clublog.password" + keyExtClublogCallsign = "extsvc.clublog.callsign" + keyExtClublogAPIKey = "extsvc.clublog.api_key" + keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" + keyExtClublogUploadMode = "extsvc.clublog.upload_mode" ) // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload @@ -99,6 +115,7 @@ type QSLDefaults struct { EQSLRcvd string `json:"eqsl_rcvd"` ClublogStatus string `json:"clublog_status"` HRDLogStatus string `json:"hrdlog_status"` + QRZComStatus string `json:"qrzcom_status"` } // CATSettings is the user-tweakable rig-control configuration. Stored as @@ -184,6 +201,7 @@ type App struct { operating *operating.Repo udp *udp.Manager udpRepo *udp.Repo + extsvc *extsvc.Manager startupErr string // captured for surfacing to the frontend dbPath string @@ -417,6 +435,17 @@ func (a *App) startup(ctx context.Context) { } } + // External-service uploaders (QRZ.com …). The manager is fed config + // from settings and host callbacks to build ADIF, stamp the upload + // status and surface errors to the UI. + a.extsvc = extsvc.NewManager(extsvc.Deps{ + BuildADIF: a.buildUploadADIF, + MarkUploaded: a.markExtUploaded, + NotifyError: a.notifyExtError, + Logf: applog.Printf, + }) + a.extsvc.SetConfig(a.loadExternalServices()) + fmt.Println("OpsLog: db ready at", a.dbPath) } @@ -648,7 +677,11 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) { a.applyStationDefaults(&q) a.applyDXCCNumber(&q) a.applyQSLDefaults(&q) - return a.qso.Add(a.ctx, q) + id, err := a.qso.Add(a.ctx, q) + if err == nil && a.extsvc != nil { + a.extsvc.OnQSOLogged(id) + } + return id, err } // StationInfoComputed bundles the data we resolve live from the @@ -1171,6 +1204,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) { keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd, keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd, keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus, + keyQSLDefaultQRZComStatus, ) if err != nil { return out, err @@ -1183,6 +1217,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) { out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd] out.ClublogStatus = m[keyQSLDefaultClublogStatus] out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus] + out.QRZComStatus = m[keyQSLDefaultQRZComStatus] return out, nil } @@ -1201,6 +1236,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error { keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)), keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)), keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)), + keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)), } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -1229,6 +1265,163 @@ func (a *App) applyQSLDefaults(q *qso.QSO) { if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd } if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus } if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus } + if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus } +} + +// ── External services (logbook upload) ───────────────────────────────── + +// loadExternalServices reads the configured external-service settings. +func (a *App) loadExternalServices() extsvc.ExternalServices { + var out extsvc.ExternalServices + if a.settings == nil { + return out + } + m, err := a.settings.GetMany(a.ctx, + keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode, + keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, + keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode) + if err != nil { + return out + } + out.QRZ = extsvc.ServiceConfig{ + APIKey: m[keyExtQRZAPIKey], + ForceStationCallsign: m[keyExtQRZForceCall], + AutoUpload: m[keyExtQRZAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]), + } + out.Clublog = extsvc.ServiceConfig{ + Email: m[keyExtClublogEmail], + Password: m[keyExtClublogPassword], + Callsign: m[keyExtClublogCallsign], + APIKey: m[keyExtClublogAPIKey], + AutoUpload: m[keyExtClublogAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]), + } + // Default the Club Log logbook callsign to the active profile's call + // when the user hasn't overridden it. + if out.Clublog.Callsign == "" && a.profiles != nil { + if p, perr := a.profiles.Active(a.ctx); perr == nil { + out.Clublog.Callsign = p.Callsign + } + } + return out +} + +// GetExternalServices returns the saved external-service configuration. +func (a *App) GetExternalServices() (extsvc.ExternalServices, error) { + return a.loadExternalServices(), nil +} + +// SaveExternalServices persists the config and reloads the live manager so +// the next logged QSO uses the new settings (no restart needed). +func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + mode := string(extsvc.ModeImmediate) + if cfg.QRZ.UploadMode == extsvc.ModeDelayed { + mode = string(extsvc.ModeDelayed) + } + auto := "0" + if cfg.QRZ.AutoUpload { + auto = "1" + } + clMode := string(extsvc.ModeImmediate) + if cfg.Clublog.UploadMode == extsvc.ModeDelayed { + clMode = string(extsvc.ModeDelayed) + } + clAuto := "0" + if cfg.Clublog.AutoUpload { + clAuto = "1" + } + for k, v := range map[string]string{ + keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), + keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)), + keyExtQRZAutoUpload: auto, + keyExtQRZUploadMode: mode, + + keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email), + keyExtClublogPassword: cfg.Clublog.Password, + keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)), + keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey), + keyExtClublogAutoUpload: clAuto, + keyExtClublogUploadMode: clMode, + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + if a.extsvc != nil { + a.extsvc.SetConfig(a.loadExternalServices()) + } + return nil +} + +// TestQRZUpload validates the configured QRZ key by querying the logbook's +// status (ACTION=STATUS). Returns a human-readable message for the UI. +func (a *App) TestQRZUpload() (string, error) { + cfg := a.loadExternalServices().QRZ + return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey) +} + +// TestClublogUpload validates that the Club Log credentials are complete. +func (a *App) TestClublogUpload() (string, error) { + return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) +} + +// buildUploadADIF builds a single-record ADIF for QSO id, overriding the +// station callsign when forceCall is set (QRZ rejects QSOs whose station +// call differs from the logbook's registered call). ok=false → skip. +func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) { + if a.qso == nil { + return "", false + } + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + return "", false + } + if forceCall != "" { + q.StationCallsign = forceCall + } + return adif.SingleRecordADIF(q), true +} + +// markExtUploaded stamps the per-service upload status on the QSO row and +// tells the frontend to refresh that row's confirmation columns. +func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { + date := time.Now().UTC().Format("20060102") + switch svc { + case extsvc.ServiceQRZ: + if a.qso != nil { + if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil { + applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err) + } + } + case extsvc.ServiceClublog: + if a.qso != nil { + if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil { + applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err) + } + } + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ + "service": string(svc), + "qso_id": id, + "log_id": logID, + }) + } +} + +// notifyExtError surfaces a failed upload to the frontend. +func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{ + "service": string(svc), + "qso_id": id, + "error": err.Error(), + }) + } } // ── UDP integrations ─────────────────────────────────────────────────── @@ -1286,7 +1479,8 @@ func (a *App) ReloadUDPIntegrations() []string { // LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the // first record into the local logbook. Returns the ID of the inserted -// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert). +// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert / +// N1MM — the latter via a synthesised ADIF record from its XML datagram). func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if a.qso == nil { return 0, fmt.Errorf("db not initialized") @@ -1379,6 +1573,9 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if err != nil { return 0, fmt.Errorf("insert qso: %w", err) } + if a.extsvc != nil { + a.extsvc.OnQSOLogged(id) + } return id, nil } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ed22f7..38958a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,7 @@ import { BandMap } from '@/components/BandMap'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; -import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; +import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; @@ -426,6 +426,10 @@ export default function App() { // already-worked). Otherwise only matching spots pass. type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; const [clusterStatusFilter, setClusterStatusFilter] = useState>(new Set()); + // Mode filter chips. Empty set = show every mode. Categories map the + // inferred per-spot mode onto SSB (phone) / CW / DATA (digital). + type SpotModeCat = 'SSB' | 'CW' | 'DATA'; + const [clusterModeFilter, setClusterModeFilter] = useState>(new Set()); const [clusterSearch, setClusterSearch] = useState(''); const [showBandMap, setShowBandMap] = useState(false); type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source'; @@ -1279,8 +1283,7 @@ export default function App() { className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card" value={callsign} onChange={(e) => onCallsignInput(e.target.value)} - placeholder="F4XYZ" - /> + />
@@ -1443,7 +1446,7 @@ export default function App() { setComment(e.target.value)} />
{!compact && ( -
+
setNote(e.target.value)} />
)} @@ -1777,6 +1780,32 @@ export default function App() { ); })} +
+ Mode: + {([ + { k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' }, + { k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' }, + { k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' }, + ]).map((s) => { + const on = clusterModeFilter.has(s.k); + return ( + + ); + })}
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx index d8b1cb4..90c3bf3 100644 --- a/frontend/src/components/RecentQSOsGrid.tsx +++ b/frontend/src/components/RecentQSOsGrid.tsx @@ -140,6 +140,8 @@ const COL_CATALOG: ColEntry[] = [ { group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 }, + { group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, + { group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 }, // ── Contest ── { group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 }, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e38b472..19a74a4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ChevronDown, ChevronRight, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, - Compass, Wifi, Construction, + Compass, Wifi, Construction, UploadCloud, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -17,6 +17,7 @@ import { ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetQSLDefaults, SaveQSLDefaults, + GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, ComputeStationInfo, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; @@ -167,6 +168,7 @@ type SectionId = | 'profiles' | 'operating' | 'confirmations' + | 'external-services' | 'udp' | 'lookup' | 'lists-bands' @@ -190,6 +192,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' }, { kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' }, { kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' }, + { kind: 'item', label: 'External services (QRZ.com, Clublog, LoTW…)', id: 'external-services' }, ], }, { @@ -221,6 +224,7 @@ const SECTION_LABELS: Partial> = { profiles: 'Profiles', operating: 'Operating conditions', confirmations: 'Confirmations', + 'external-services': 'External services', lookup: 'Callsign Lookup', 'lists-bands': 'Bands', 'lists-modes': 'Modes & default RST', @@ -368,15 +372,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { qsl_sent: string; qsl_rcvd: string; lotw_sent: string; lotw_rcvd: string; eqsl_sent: string; eqsl_rcvd: string; - clublog_status: string; hrdlog_status: string; + clublog_status: string; hrdlog_status: string; qrzcom_status: string; }; const [qslDefaults, setQslDefaults] = useState({ qsl_sent: '', qsl_rcvd: '', lotw_sent: '', lotw_rcvd: '', eqsl_sent: '', eqsl_rcvd: '', - clublog_status: '', hrdlog_status: '', + clublog_status: '', hrdlog_status: '', qrzcom_status: '', }); + // External services (logbook upload). One block per service; only QRZ is + // wired today. upload_mode is 'immediate' | 'delayed' (per-service). + type ExtServiceCfg = { + api_key: string; email: string; password: string; callsign: string; + force_station_callsign: string; + auto_upload: boolean; upload_mode: string; + }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg }; + const emptyExtCfg = (): ExtServiceCfg => ({ + api_key: '', email: '', password: '', callsign: '', + force_station_callsign: '', auto_upload: false, upload_mode: 'immediate', + }); + const [extSvc, setExtSvc] = useState({ + qrz: emptyExtCfg(), clublog: emptyExtCfg(), + }); + const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [qrzTesting, setQrzTesting] = useState(false); + const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [clublogTesting, setClublogTesting] = useState(false); + // Active tab in the External Services panel — lifted here because + // PANELS[selected]() is called as a function, so panels can't hold hooks. + const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz'); + const [backupCfg, setBackupCfg] = useState({ enabled: false, folder: '', rotation: 5, zip: false, last_backup_at: '', default_folder: '', @@ -450,9 +477,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { useEffect(() => { (async () => { try { - const [l, ls, c, ap, r, b, qd] = await Promise.all([ + const [l, ls, c, ap, r, b, qd, es] = await Promise.all([ GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(), - GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), + GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(), ]); setLookup(l); setActiveProfile(ap as Profile); @@ -463,6 +490,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setRotator(r); setBackupCfg(b as any); setQslDefaults(qd as any); + setExtSvc(es as any); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { @@ -582,6 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { await SaveRotatorSettings(rotator as any); await SaveBackupSettings(backupCfg as any); await SaveQSLDefaults(qslDefaults as any); + await SaveExternalServices(extSvc as any); await SetClusterAutoConnect(clusterAutoConnect); setMsg('Settings saved.'); @@ -1555,7 +1584,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
- Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded. + Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
{/* Clublog */}
@@ -1575,6 +1604,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
+ {/* QRZ.com */} +
+ +
+ + {renderSelect('qrzcom_status', FULL_OPTIONS)} +
+
+
@@ -1727,12 +1765,227 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { ); } + function ExternalServicesPanel() { + const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [ + { k: 'qrz', label: 'QRZ.COM', ready: true }, + { k: 'clublog', label: 'CLUBLOG', ready: true }, + { k: 'hrdlog', label: 'HRDLOG.NET' }, + { k: 'eqsl', label: 'EQSL' }, + { k: 'hamqth', label: 'HAMQTH' }, + { k: 'lotw', label: 'LOTW' }, + ]; + const qrz = extSvc.qrz; + const setQrz = (patch: Partial) => + setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } })); + const clublog = extSvc.clublog; + const setClublog = (patch: Partial) => + setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } })); + + async function testQrz() { + setQrzTesting(true); + setQrzTest(null); + try { + // Persist first so the backend test reads the key just typed. + await SaveExternalServices(extSvc as any); + const msg = await TestQRZUpload(); + setQrzTest({ ok: true, msg }); + } catch (e: any) { + setQrzTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setQrzTesting(false); + } + } + + async function testClublog() { + setClublogTesting(true); + setClublogTest(null); + try { + await SaveExternalServices(extSvc as any); + const msg = await TestClublogUpload(); + setClublogTest({ ok: true, msg }); + } catch (e: any) { + setClublogTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setClublogTesting(false); + } + } + + return ( + <> + + + {/* Tab strip */} +
+ {TABS.map((t) => ( + + ))} +
+ + {extSvcTab === 'qrz' ? ( +
+
+ + setQrz({ api_key: e.target.value })} + placeholder="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)" + className="font-mono text-xs" + /> + + setQrz({ force_station_callsign: e.target.value.toUpperCase() })} + placeholder="e.g. F4BPO — optional" + className="font-mono text-xs" + /> +
+ +
+ QRZ.com discards station calls that differ from the one registered on the logbook. + Setting your registered callsign here rewrites STATION_CALLSIGN on + upload, so a QSO logged with a /P or /QRP suffix + is still accepted. Note this also applies to QSOs made with a country prefix/suffix. +
+ +
+ + +
+ + +
+ +
+ + {qrzTest && ( + + {qrzTest.msg} + + )} +
+
+
+ ) : extSvcTab === 'clublog' ? ( +
+
+ + setClublog({ email: e.target.value })} + placeholder="your Club Log account email" + className="text-xs" + /> + + setClublog({ password: e.target.value })} + placeholder="Club Log account password" + className="text-xs" + /> + + setClublog({ callsign: e.target.value.toUpperCase() })} + placeholder="defaults to the active profile's callsign" + className="font-mono text-xs" + /> +
+ +
+ Club Log uploads each QSO in real time using your account email, password and the + logbook callsign — no API key needed for QSO upload. +
+ +
+ + +
+ + +
+ +
+ + {clublogTest && ( + + {clublogTest.msg} + + )} +
+
+
+ ) : ( +
+ +
+ {TABS.find((t) => t.k === extSvcTab)?.label} — coming soon +
+
This external service isn't wired up yet.
+
+ )} + + ); + } + // Map sections to their content + icon (for placeholder). const PANELS: Record JSX.Element> = { station: StationPanel, profiles: ProfilesPanel, operating: OperatingPanelWrapper, confirmations: ConfirmationsPanel, + 'external-services': ExternalServicesPanel, lookup: LookupPanel, 'lists-bands': BandsPanel, 'lists-modes': ModesPanel, diff --git a/frontend/src/lib/spot.ts b/frontend/src/lib/spot.ts index fd1e5cc..cf69536 100644 --- a/frontend/src/lib/spot.ts +++ b/frontend/src/lib/spot.ts @@ -59,6 +59,20 @@ export function inferSpotMode(comment: string, freqHz: number): string { return ''; } +// spotModeCategory buckets the fine-grained mode from inferSpotMode into +// the three families the cluster filter exposes: 'SSB' (phone: SSB/FM/AM), +// 'CW', and 'DATA' (every digital mode). Returns '' when the mode is +// unknown so callers can decide how to treat un-categorisable spots. +export function spotModeCategory(mode: string): 'SSB' | 'CW' | 'DATA' | '' { + const m = (mode || '').toUpperCase(); + if (m === '') return ''; + if (m === 'CW') return 'CW'; + if (m === 'SSB' || m === 'USB' || m === 'LSB' || m === 'FM' || m === 'AM') return 'SSB'; + // Everything else inferSpotMode can return (FT8/FT4/JS8/RTTY/PSK*/…/DATA) + // is a digital mode. + return 'DATA'; +} + // spotStatusKey is the cache key for ClusterSpotStatuses results. Must be // computed identically in the fetcher and every reader — including the // band map and the spot table — so a CW spot's status doesn't get looked diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index db15a88..b8f662c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -6,6 +6,7 @@ import {profile} from '../models'; import {adif} from '../models'; import {cat} from '../models'; import {cluster} from '../models'; +import {extsvc} from '../models'; import {operating} from '../models'; import {udp} from '../models'; import {lookup} from '../models'; @@ -62,6 +63,8 @@ export function GetClusterStatus():Promise>; export function GetCtyDatInfo():Promise; +export function GetExternalServices():Promise; + export function GetListsSettings():Promise; export function GetLogFilePath():Promise; @@ -122,6 +125,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise; +export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise; + export function SaveListsSettings(arg1:main.ListsSettings):Promise; export function SaveLookupSettings(arg1:main.LookupSettings):Promise; @@ -152,8 +157,12 @@ export function SetCompactMode(arg1:boolean):Promise; export function SwitchCATRig(arg1:number):Promise; +export function TestClublogUpload():Promise; + export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; +export function TestQRZUpload():Promise; + export function TestRotator(arg1:main.RotatorSettings):Promise; export function UpdateQSO(arg1:qso.QSO):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 1db1f6d..8e82422 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -106,6 +106,10 @@ export function GetCtyDatInfo() { return window['go']['main']['App']['GetCtyDatInfo'](); } +export function GetExternalServices() { + return window['go']['main']['App']['GetExternalServices'](); +} + export function GetListsSettings() { return window['go']['main']['App']['GetListsSettings'](); } @@ -226,6 +230,10 @@ export function SaveClusterServer(arg1) { return window['go']['main']['App']['SaveClusterServer'](arg1); } +export function SaveExternalServices(arg1) { + return window['go']['main']['App']['SaveExternalServices'](arg1); +} + export function SaveListsSettings(arg1) { return window['go']['main']['App']['SaveListsSettings'](arg1); } @@ -286,10 +294,18 @@ export function SwitchCATRig(arg1) { return window['go']['main']['App']['SwitchCATRig'](arg1); } +export function TestClublogUpload() { + return window['go']['main']['App']['TestClublogUpload'](); +} + export function TestLookupProvider(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); } +export function TestQRZUpload() { + return window['go']['main']['App']['TestQRZUpload'](); +} + export function TestRotator(arg1) { return window['go']['main']['App']['TestRotator'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1a22f5a..7d32caa 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -183,6 +183,67 @@ export namespace cluster { } +export namespace extsvc { + + export class ServiceConfig { + api_key: string; + email: string; + password: string; + callsign: string; + force_station_callsign: string; + auto_upload: boolean; + upload_mode: string; + + static createFrom(source: any = {}) { + return new ServiceConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.api_key = source["api_key"]; + this.email = source["email"]; + this.password = source["password"]; + this.callsign = source["callsign"]; + this.force_station_callsign = source["force_station_callsign"]; + this.auto_upload = source["auto_upload"]; + this.upload_mode = source["upload_mode"]; + } + } + export class ExternalServices { + qrz: ServiceConfig; + clublog: ServiceConfig; + + static createFrom(source: any = {}) { + return new ExternalServices(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.qrz = this.convertValues(source["qrz"], ServiceConfig); + this.clublog = this.convertValues(source["clublog"], ServiceConfig); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace lookup { export class Result { @@ -403,6 +464,7 @@ export namespace main { eqsl_rcvd: string; clublog_status: string; hrdlog_status: string; + qrzcom_status: string; static createFrom(source: any = {}) { return new QSLDefaults(source); @@ -418,6 +480,7 @@ export namespace main { this.eqsl_rcvd = source["eqsl_rcvd"]; this.clublog_status = source["clublog_status"]; this.hrdlog_status = source["hrdlog_status"]; + this.qrzcom_status = source["qrzcom_status"]; } } export class RotatorSettings { @@ -847,6 +910,8 @@ export namespace qso { clublog_qso_upload_status?: string; hrdlog_qso_upload_date?: string; hrdlog_qso_upload_status?: string; + qrzcom_qso_upload_date?: string; + qrzcom_qso_upload_status?: string; contest_id?: string; srx?: number; stx?: number; @@ -950,6 +1015,8 @@ export namespace qso { this.clublog_qso_upload_status = source["clublog_qso_upload_status"]; this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"]; this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"]; + this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"]; + this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"]; this.contest_id = source["contest_id"]; this.srx = source["srx"]; this.stx = source["stx"]; diff --git a/internal/adif/export.go b/internal/adif/export.go index e4a7f28..2e3b770 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -77,6 +77,18 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) { return count, err } +// SingleRecordADIF returns one QSO serialised as an ADIF record (fields +// terminated by ), with no document header. Used by the external- +// service uploaders (QRZ.com / Clublog / …) which want a bare record as +// the ADIF parameter of their HTTP API. +func SingleRecordADIF(q qso.QSO) string { + var b strings.Builder + bw := bufio.NewWriter(&b) + writeRecord(bw, q) + bw.Flush() + return b.String() +} + // writeRecord serialises one QSO as ADIF tags terminated by . // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // mode (e.g. FT4 stored without a parent) is exported as the canonical @@ -155,6 +167,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) { writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus) writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate) writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) + writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate) + writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus) // --- Contest --- writeField(bw, "CONTEST_ID", q.ContestID) diff --git a/internal/adif/import.go b/internal/adif/import.go index fe4645d..3c1cf42 100644 --- a/internal/adif/import.go +++ b/internal/adif/import.go @@ -183,6 +183,7 @@ var adifPromoted = stringSet( "eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate", "clublog_qso_upload_date", "clublog_qso_upload_status", "hrdlog_qso_upload_date", "hrdlog_qso_upload_status", + "qrzcom_qso_upload_date", "qrzcom_qso_upload_status", // Contest "contest_id", "srx", "stx", "srx_string", "stx_string", "check", "precedence", "arrl_sect", @@ -312,6 +313,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) { q.ClublogUploadStatus = rec["clublog_qso_upload_status"] q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"] q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"] + q.QRZComUploadDate = rec["qrzcom_qso_upload_date"] + q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"] // Contest q.ContestID = rec["contest_id"] diff --git a/internal/db/migrations/0014_qrzcom_upload.sql b/internal/db/migrations/0014_qrzcom_upload.sql new file mode 100644 index 0000000..0388e19 --- /dev/null +++ b/internal/db/migrations/0014_qrzcom_upload.sql @@ -0,0 +1,6 @@ +-- QRZ.com Logbook upload tracking. Like Clublog / HRDLog, QRZ.com is an +-- upload target: we stamp QRZCOM_QSO_UPLOAD_STATUS (and DATE) so OpsLog +-- can track which QSOs still need pushing and round-trip the standard +-- ADIF fields. Confirmations panel exposes the default status. +ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_date TEXT; +ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_status TEXT; diff --git a/internal/dxcc/adif_numbers.go b/internal/dxcc/adif_numbers.go index e1c77c8..3f553b0 100644 --- a/internal/dxcc/adif_numbers.go +++ b/internal/dxcc/adif_numbers.go @@ -146,7 +146,11 @@ var dxccByName = map[string]int{ "liechtenstein": 251, "austria": 206, "italy": 248, - "sicily": 225, + // Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not + // DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own + // DXCC entity (225) and keeps its number. + "sicily": 248, + "african italy": 248, "sardinia": 225, "spain": 281, "portugal": 272, @@ -318,3 +322,23 @@ func EntityDXCC(name string) int { } return dxccByName[strings.ToLower(strings.TrimSpace(name))] } + +// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone / +// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong +// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the +// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an +// entry here for any other split that should report its parent entity. +var ctyEntityAliases = map[string]string{ + "sicily": "Italy", + "african italy": "Italy", + // NB: Sardinia is intentionally absent — it's a real DXCC entity. +} + +// CanonicalEntityName normalises a cty.dat entity name to its ARRL DXCC +// entity. Names that already are DXCC entities pass through unchanged. +func CanonicalEntityName(name string) string { + if c, ok := ctyEntityAliases[strings.ToLower(strings.TrimSpace(name))]; ok { + return c + } + return name +} diff --git a/internal/dxcc/dxcc.go b/internal/dxcc/dxcc.go index 172b410..0f52e4a 100644 --- a/internal/dxcc/dxcc.go +++ b/internal/dxcc/dxcc.go @@ -181,10 +181,22 @@ func parseEntityHeader(line string) *Entity { if len(parts) < 8 { return nil } + name := strings.TrimSpace(parts[0]) + primary := strings.TrimSpace(parts[7]) + // cty.dat marks non-DXCC entities (WAE / contest-only zone splits such + // as Sicily *IT9 and African Italy *IG9) with a leading '*' on the + // primary prefix. Those report under their parent DXCC entity. True + // DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no + // '*' and keep their own name. Per-prefix zones/lat-lon are preserved, + // so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy". + if strings.HasPrefix(primary, "*") { + primary = strings.TrimPrefix(primary, "*") + name = CanonicalEntityName(name) + } e := &Entity{ - Name: strings.TrimSpace(parts[0]), + Name: name, Continent: strings.TrimSpace(parts[3]), - Primary: strings.TrimSpace(parts[7]), + Primary: primary, } e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1])) e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2])) diff --git a/internal/dxcc/dxcc_test.go b/internal/dxcc/dxcc_test.go index d27683c..4f525b5 100644 --- a/internal/dxcc/dxcc_test.go +++ b/internal/dxcc/dxcc_test.go @@ -54,6 +54,54 @@ func TestLookup(t *testing.T) { } } +// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a +// leading '*'; the parser must fold those into their parent DXCC entity +// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone. +func TestCanonicalEntityNames(t *testing.T) { + const cty = `Italy: 15: 28: EU: 42.82: -12.58: -1.0: I: + I,IK,IZ; +African Italy: 33: 37: AF: 35.67: -12.67: -1.0: *IG9: + IG9,IH9; +Sardinia: 15: 28: EU: 40.15: -9.27: -1.0: IS0: + IM0,IS,IW0U,IW0V; +Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9: + IT9,IW9; +` + db, err := Load(strings.NewReader(cty)) + if err != nil { + t.Fatalf("load: %v", err) + } + cases := map[string]string{ + "IW9EZO": "Italy", // Sicily (*IT9) → Italy + "IT9CLY": "Italy", + "IG9A": "Italy", // African Italy (*IG9) → Italy + "IK0ABC": "Italy", + "IS0XYZ": "Sardinia", // real DXCC entity — must stay Sardinia + "IM0ABC": "Sardinia", + } + for call, want := range cases { + m, ok := db.Lookup(call) + if !ok { + t.Errorf("%s: no match", call) + continue + } + if m.Entity.Name != want { + t.Errorf("%s: country = %q, want %q", call, m.Entity.Name, want) + } + } + // African Italy keeps its own zones/continent even though it reports + // Italy as the entity. + if m, _ := db.Lookup("IG9A"); m.CQZone != 33 || m.Continent != "AF" { + t.Errorf("IG9A: got CQ=%d cont=%s, want 33/AF", m.CQZone, m.Continent) + } + if EntityDXCC("Sicily") != 248 { + t.Errorf("EntityDXCC(Sicily) = %d, want 248 (Italy)", EntityDXCC("Sicily")) + } + if EntityDXCC("Sardinia") != 225 { + t.Errorf("EntityDXCC(Sardinia) = %d, want 225", EntityDXCC("Sardinia")) + } +} + func TestNormalize(t *testing.T) { cases := map[string]string{ "F4BPO": "F4BPO", diff --git a/internal/extsvc/clublog.go b/internal/extsvc/clublog.go new file mode 100644 index 0000000..ded7b98 --- /dev/null +++ b/internal/extsvc/clublog.go @@ -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") +} diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go new file mode 100644 index 0000000..50c55f0 --- /dev/null +++ b/internal/extsvc/extsvc.go @@ -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 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 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 1–2 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) +} diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go new file mode 100644 index 0000000..7a7bb4d --- /dev/null +++ b/internal/extsvc/manager.go @@ -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 1–2 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 60–120s 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) + } +} diff --git a/internal/extsvc/qrz.go b/internal/extsvc/qrz.go new file mode 100644 index 0000000..fcda820 --- /dev/null +++ b/internal/extsvc/qrz.go @@ -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=&ACTION=INSERT&ADIF=&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") +} diff --git a/internal/extsvc/qrz_test.go b/internal/extsvc/qrz_test.go new file mode 100644 index 0000000..74e0735 --- /dev/null +++ b/internal/extsvc/qrz_test.go @@ -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) + } + }) + } +} diff --git a/internal/integrations/udp/n1mm.go b/internal/integrations/udp/n1mm.go new file mode 100644 index 0000000..f682ae9 --- /dev/null +++ b/internal/integrations/udp/n1mm.go @@ -0,0 +1,219 @@ +package udp + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "strings" + "time" +) + +// N1MM Logger+ broadcasts each logged contact as a UTF-8 XML datagram. +// We care about the two that represent a completed QSO: +// +// – a freshly logged contact +// – an edited contact (same shape; we treat it as a log) +// +// Everything else N1MM emits on the same socket — , , +// , , — is ignored here (spots +// are a separate feature; deletes/status aren't auto-logged). +// +// N1MM frequencies are in tens of Hz (rxfreq 1402500 == 14.025 MHz), and +// the tag is the band edge in MHz as a bare number ("14", "3.5"). +// We derive the ADIF band from the frequency when we have it and fall back +// to the band tag otherwise. +// +// Rather than build a qso.QSO by hand we synthesise an ADIF record and feed +// it back through the same auto-log path WSJT-X uses (LogUDPLoggedADIF): +// that gets us lookup enrichment, DXCC stamping, the operating-conditions +// stamp and dedup for free. + +// n1mmContact maps the subset of / fields we +// promote into the logbook. Unmapped tags are dropped; anything we keep but +// the ADIF importer doesn't promote lands in the QSO's Extras. +type n1mmContact struct { + Call string `xml:"call"` + Mode string `xml:"mode"` + Band string `xml:"band"` // band edge in MHz, e.g. "14" + RxFreq string `xml:"rxfreq"` // tens of Hz; string so empty decodes cleanly + TxFreq string `xml:"txfreq"` + Timestamp string `xml:"timestamp"` // "2006-01-02 15:04:05", UTC + MyCall string `xml:"mycall"` + Operator string `xml:"operator"` + Snt string `xml:"snt"` + SntNr string `xml:"sntnr"` + Rcv string `xml:"rcv"` + RcvNr string `xml:"rcvnr"` + Grid string `xml:"gridsquare"` + Name string `xml:"name"` + QTH string `xml:"qth"` + Comment string `xml:"comment"` + Power string `xml:"power"` + ContestName string `xml:"contestname"` +} + +// ParseN1MM decodes one N1MM UDP datagram. It returns ok=false (with no +// error) for datagrams that aren't a loggable contact. For a contact it +// returns a synthesised ADIF record ready for the auto-log path. +func ParseN1MM(pkt []byte) (adifText string, ok bool, err error) { + dec := xml.NewDecoder(bytes.NewReader(pkt)) + for { + tok, terr := dec.Token() + if terr != nil { + // EOF before any start element, or malformed XML. + return "", false, fmt.Errorf("n1mm: no element: %w", terr) + } + se, isStart := tok.(xml.StartElement) + if !isStart { + continue + } + switch se.Name.Local { + case "contactinfo", "contactreplace": + var c n1mmContact + if derr := dec.DecodeElement(&c, &se); derr != nil { + return "", false, fmt.Errorf("n1mm: decode %s: %w", se.Name.Local, derr) + } + return c.toADIF() + default: + // spot / RadioInfo / dynamicresults / contactdelete / etc. + return "", false, nil + } + } +} + +// toADIF turns a parsed contact into an ADIF record string. Returns +// ok=false if the required call/mode/date fields are missing — better to +// skip silently than to hand the auto-log path an unloggable record. +func (c n1mmContact) toADIF() (string, bool, error) { + call := strings.ToUpper(strings.TrimSpace(c.Call)) + mode := normaliseN1MMMode(c.Mode) + t, terr := parseN1MMTimestamp(c.Timestamp) + if call == "" || mode == "" || terr != nil { + return "", false, nil + } + + var freqHz int64 + if raw := strings.TrimSpace(c.RxFreq); raw != "" { + if tens, perr := strconv.ParseInt(raw, 10, 64); perr == nil { + freqHz = tens * 10 + } + } + band := bandFromHz(freqHz) + if band == "" { + band = bandFromMHzTag(c.Band) + } + if band == "" { + // No band, no log — the importer requires it. + return "", false, nil + } + + var b strings.Builder + writeADIFField(&b, "call", call) + writeADIFField(&b, "qso_date", t.Format("20060102")) + writeADIFField(&b, "time_on", t.Format("150405")) + writeADIFField(&b, "band", band) + writeADIFField(&b, "mode", mode) + if freqHz > 0 { + // MHz with kHz precision, ADIF style: "14.025000". + writeADIFField(&b, "freq", strconv.FormatFloat(float64(freqHz)/1e6, 'f', 6, 64)) + } + writeADIFField(&b, "rst_sent", strings.TrimSpace(c.Snt)) + writeADIFField(&b, "rst_rcvd", strings.TrimSpace(c.Rcv)) + writeADIFField(&b, "gridsquare", strings.TrimSpace(c.Grid)) + writeADIFField(&b, "name", strings.TrimSpace(c.Name)) + writeADIFField(&b, "qth", strings.TrimSpace(c.QTH)) + writeADIFField(&b, "comment", strings.TrimSpace(c.Comment)) + writeADIFField(&b, "tx_pwr", strings.TrimSpace(c.Power)) + writeADIFField(&b, "operator", strings.ToUpper(strings.TrimSpace(c.MyCall))) + writeADIFField(&b, "contest_id", strings.TrimSpace(c.ContestName)) + writeADIFField(&b, "stx", strings.TrimSpace(c.SntNr)) + writeADIFField(&b, "srx", strings.TrimSpace(c.RcvNr)) + b.WriteString("\n") + return b.String(), true, nil +} + +// writeADIFField appends a single "value" field, skipping empties. +func writeADIFField(b *strings.Builder, name, value string) { + if value == "" { + return + } + fmt.Fprintf(b, "<%s:%d>%s", name, len(value), value) +} + +// normaliseN1MMMode maps N1MM mode strings onto ADIF modes. N1MM reports +// the sideband (USB/LSB) where ADIF wants the parent mode SSB; everything +// else passes through upper-cased. +func normaliseN1MMMode(mode string) string { + m := strings.ToUpper(strings.TrimSpace(mode)) + switch m { + case "USB", "LSB": + return "SSB" + default: + return m + } +} + +// parseN1MMTimestamp parses N1MM's "2006-01-02 15:04:05" UTC timestamp. +func parseN1MMTimestamp(ts string) (time.Time, error) { + return time.Parse("2006-01-02 15:04:05", strings.TrimSpace(ts)) +} + +// n1mmBand is one entry in the band-plan table used to derive an ADIF band +// from a dial frequency. +type n1mmBand struct { + loHz, hiHz int64 + name string +} + +// bandPlan covers the HF/VHF/UHF allocations a logger is likely to see. +// Ranges are generous (band edges, not country sub-bands) so an out-of-band +// dial reading still maps to the nearest band. +var bandPlan = []n1mmBand{ + {1_800_000, 2_000_000, "160m"}, + {3_500_000, 4_000_000, "80m"}, + {5_060_000, 5_450_000, "60m"}, + {7_000_000, 7_300_000, "40m"}, + {10_100_000, 10_150_000, "30m"}, + {14_000_000, 14_350_000, "20m"}, + {18_068_000, 18_168_000, "17m"}, + {21_000_000, 21_450_000, "15m"}, + {24_890_000, 24_990_000, "12m"}, + {28_000_000, 29_700_000, "10m"}, + {50_000_000, 54_000_000, "6m"}, + {70_000_000, 71_000_000, "4m"}, + {144_000_000, 148_000_000, "2m"}, + {222_000_000, 225_000_000, "1.25m"}, + {420_000_000, 450_000_000, "70cm"}, + {902_000_000, 928_000_000, "33cm"}, + {1_240_000_000, 1_300_000_000, "23cm"}, +} + +// bandFromHz returns the ADIF band token for a dial frequency, or "" when +// the frequency is zero or outside every known allocation. +func bandFromHz(hz int64) string { + if hz <= 0 { + return "" + } + for _, b := range bandPlan { + if hz >= b.loHz && hz <= b.hiHz { + return b.name + } + } + return "" +} + +// bandFromMHzTag maps N1MM's bare-MHz tag ("14", "3.5") onto an ADIF +// band by treating it as a frequency at the band's low edge. +func bandFromMHzTag(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + mhz, err := strconv.ParseFloat(tag, 64) + if err != nil { + return "" + } + // Nudge just inside the low edge so e.g. "14" lands in 20m. + return bandFromHz(int64(mhz*1_000_000) + 1) +} diff --git a/internal/integrations/udp/n1mm_test.go b/internal/integrations/udp/n1mm_test.go new file mode 100644 index 0000000..a5c3277 --- /dev/null +++ b/internal/integrations/udp/n1mm_test.go @@ -0,0 +1,103 @@ +package udp + +import ( + "strings" + "testing" +) + +// A representative N1MM+ datagram (trimmed to the fields we +// read). rxfreq 1402500 tens-of-Hz == 14.025 MHz → 20m. +const sampleContactInfo = ` + + DX + 2024-03-15 14:25:30 + K1ABC + 14 + 1402500 + 1402500 + K1ABC + CW + VE9AA + 599 + 1 + 599 + 42 + FN65 + Mike + tnx + 100 +` + +func TestParseN1MMContactInfo(t *testing.T) { + adif, ok, err := ParseN1MM([]byte(sampleContactInfo)) + if err != nil { + t.Fatalf("ParseN1MM error: %v", err) + } + if !ok { + t.Fatal("expected a loggable contact, got ok=false") + } + want := map[string]string{ + "VE9AA": "callsign", + "20240315": "date", + "142530": "time", + "20m": "band", + "CW": "mode", + "14.025000": "freq", + "599": "rst sent", + "599": "rst rcvd", + "FN65": "grid", + "Mike": "name", + "1": "stx serial", + "42": "srx serial", + "": "terminator", + } + for sub, label := range want { + if !strings.Contains(adif, sub) { + t.Errorf("missing %s field %q in:\n%s", label, sub, adif) + } + } +} + +func TestParseN1MMSSBMapping(t *testing.T) { + pkt := `F4XYZUSB1414200002024-01-01 00:00:00` + adif, ok, err := ParseN1MM([]byte(pkt)) + if err != nil || !ok { + t.Fatalf("ParseN1MM ok=%v err=%v", ok, err) + } + if !strings.Contains(adif, "SSB") { + t.Errorf("USB should map to SSB, got:\n%s", adif) + } +} + +func TestParseN1MMIgnoresNonContacts(t *testing.T) { + for _, pkt := range []string{ + `N1MM1402500`, + `VE9AA14025`, + `VE9AA`, + } { + _, ok, err := ParseN1MM([]byte(pkt)) + if err != nil { + t.Errorf("unexpected error for %q: %v", pkt, err) + } + if ok { + t.Errorf("expected ok=false (ignored) for %q", pkt) + } + } +} + +func TestBandFromHz(t *testing.T) { + cases := map[int64]string{ + 14_025_000: "20m", + 7_100_000: "40m", + 3_650_000: "80m", + 28_400_000: "10m", + 144_200_000: "2m", + 0: "", + 15_000_000: "", // between 20m and 17m → no band + } + for hz, want := range cases { + if got := bandFromHz(hz); got != want { + t.Errorf("bandFromHz(%d) = %q, want %q", hz, got, want) + } + } +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index 47c821d..e6a3167 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -45,8 +45,7 @@ type Event struct { DXGrid string // ServiceWSJT (Status) Mode string // ServiceWSJT (Status) FreqHz int64 // ServiceWSJT (Status) - LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF - RawText string // generic fallback (n1mm xml, etc.) + LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM } // Server is a single inbound UDP listener. @@ -187,7 +186,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.FreqHz = w.FreqHz ev.LoggedADIF = w.LoggedADIF case ServiceADIF: - ev.LoggedADIF = string(pkt) + // JTAlert / GridTracker forward a text ADIF record after a QSO is + // logged. Guard against keep-alive / non-ADIF chatter on the socket: + // only forward payloads that actually carry a callsign field and a + // record terminator. + text := string(pkt) + low := strings.ToLower(text) + if !strings.Contains(low, "/)\n", s.cfg.Name) + return + } + ev.LoggedADIF = text case ServiceRemoteCall: // Common payload shapes seen in the wild: // "F4XYZ" (bare callsign) @@ -217,12 +226,22 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { } ev.DXCall = strings.ToUpper(parts[len(parts)-1]) case ServiceN1MM: - ev.RawText = string(pkt) + adifText, ok, err := ParseN1MM(pkt) + if err != nil { + applog.Printf("udp: [%s] N1MM parse error: %v\n", s.cfg.Name, err) + return + } + if !ok { + applog.Printf("udp: [%s] N1MM datagram ignored (not a loggable contact)\n", s.cfg.Name) + return + } + applog.Printf("udp: [%s] N1MM contact decoded (%d bytes ADIF)\n", s.cfg.Name, len(adifText)) + ev.LoggedADIF = adifText default: return } // Empty events are useless; skip. - if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" { + if ev.DXCall == "" && ev.LoggedADIF == "" { return } select { diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index 2efd9ef..ec821e7 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -88,16 +88,21 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { return Result{}, fmt.Errorf("empty callsign") } - if r, ok := m.cache.Get(ctx, call); ok { - r.Source = "cache" - return r, nil - } - m.mu.RLock() providers := append([]Provider(nil), m.providers...) dxcc := m.dxcc m.mu.RUnlock() + if r, ok := m.cache.Get(ctx, call); ok { + r.Source = "cache" + // Re-assert the authoritative DXCC fields (country/zones/continent) + // from cty.dat on every cache hit — cheap (in-memory) and lets a + // corrected entity mapping (e.g. Sicily → Italy) heal stale cached + // rows without waiting for the TTL to expire. + fillFromDXCC(&r, dxcc) + return r, nil + } + var lastErr error for _, p := range providers { r, err := p.Lookup(ctx, call) diff --git a/internal/qso/qso.go b/internal/qso/qso.go index bc4dd2a..f8d15f3 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -76,6 +76,8 @@ type QSO struct { ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"` HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"` HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"` + QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"` + QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"` // --- Contest --- ContestID string `json:"contest_id,omitempty"` @@ -164,6 +166,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date, clublog_qso_upload_date, clublog_qso_upload_status, hrdlog_qso_upload_date, hrdlog_qso_upload_status, + qrzcom_qso_upload_date, qrzcom_qso_upload_status, contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect, prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path, station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota, @@ -215,6 +218,7 @@ func (q *QSO) args() []any { q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate, q.ClublogUploadDate, q.ClublogUploadStatus, q.HRDLogUploadDate, q.HRDLogUploadStatus, + q.QRZComUploadDate, q.QRZComUploadStatus, q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect, q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath, q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA, @@ -319,6 +323,34 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) { return scanQSO(row) } +// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on +// a QSO after a successful push to the QRZ.com logbook. date is an ADIF +// YYYYMMDD string. Only the two QRZ columns are touched — no full-row +// rewrite — so it's safe to call from the async upload path. +func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, + date, id) + if err != nil { + return fmt.Errorf("mark qrz uploaded %d: %w", id, err) + } + return nil +} + +// MarkClublogUploaded stamps CLUBLOG_QSO_UPLOAD_STATUS=Y and the upload +// date after a successful Club Log push. date is an ADIF YYYYMMDD string. +func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, + date, id) + if err != nil { + return fmt.Errorf("mark clublog uploaded %d: %w", id, err) + } + return nil +} + // Update overwrites all editable fields of an existing QSO. updated_at is bumped. func (r *Repo) Update(ctx context.Context, q QSO) error { if q.ID == 0 { @@ -1004,6 +1036,7 @@ func scanQSO(s scanner) (QSO, error) { eqslSentDate, eqslRcvdDate sql.NullString clublogDate, clublogStatus sql.NullString hrdlogDate, hrdlogStatus sql.NullString + qrzcomDate, qrzcomStatus sql.NullString contestID sql.NullString srx, stx sql.NullInt64 srxStr, stxStr sql.NullString @@ -1035,6 +1068,7 @@ func scanQSO(s scanner) (QSO, error) { &eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate, &clublogDate, &clublogStatus, &hrdlogDate, &hrdlogStatus, + &qrzcomDate, &qrzcomStatus, &contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect, &propMode, &satName, &satMode, &antAz, &antEl, &antPath, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, @@ -1120,6 +1154,8 @@ func scanQSO(s scanner) (QSO, error) { q.ClublogUploadStatus = clublogStatus.String q.HRDLogUploadDate = hrdlogDate.String q.HRDLogUploadStatus = hrdlogStatus.String + q.QRZComUploadDate = qrzcomDate.String + q.QRZComUploadStatus = qrzcomStatus.String q.ContestID = contestID.String if srx.Valid { v := int(srx.Int64)