diff --git a/app.go b/app.go index f8eb14f..1592b05 100644 --- a/app.go +++ b/app.go @@ -99,6 +99,14 @@ const ( keyExtClublogAPIKey = "extsvc.clublog.api_key" keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" keyExtClublogUploadMode = "extsvc.clublog.upload_mode" + + keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" + keyExtLoTWStationLoc = "extsvc.lotw.station_location" + keyExtLoTWKeyPassword = "extsvc.lotw.key_password" + keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" + keyExtLoTWWriteLog = "extsvc.lotw.write_log" + keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" + keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" ) // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload @@ -442,6 +450,7 @@ func (a *App) startup(ctx context.Context) { BuildADIF: a.buildUploadADIF, MarkUploaded: a.markExtUploaded, NotifyError: a.notifyExtError, + ShouldUpload: a.extShouldUpload, Logf: applog.Printf, }) a.extsvc.SetConfig(a.loadExternalServices()) @@ -511,6 +520,15 @@ func (a *App) plannedShutdownSteps() []shutdownStep { out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"}) } } + if a.extsvc != nil { + if n := a.extsvc.PendingCount(); n > 0 { + out = append(out, shutdownStep{ + ID: "extsvc-upload", + Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n), + Status: "pending", + }) + } + } return out } @@ -533,6 +551,9 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) { switch steps[i].ID { case "backup": err = a.runBackupForShutdown() + case "extsvc-upload": + n := a.extsvc.FlushOnClose() + steps[i].Detail = fmt.Sprintf("%d uploaded", n) } if err != nil { steps[i].Status = "error" @@ -718,6 +739,10 @@ func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed { out.Lat = lat out.Lon = lon } + // 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps + // the UI fields tidy. + out.Lat = math.Round(out.Lat*1000) / 1000 + out.Lon = math.Round(out.Lon*1000) / 1000 return out } @@ -1299,7 +1324,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { m, err := a.settings.GetMany(a.ctx, keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode, keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, - keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode) + keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, + keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword, + keyExtLoTWUploadFlag, keyExtLoTWWriteLog, + keyExtLoTWAutoUpload, keyExtLoTWUploadMode) if err != nil { return out } @@ -1324,6 +1352,20 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { out.Clublog.Callsign = p.Callsign } } + out.LoTW = extsvc.ServiceConfig{ + TQSLPath: m[keyExtLoTWTQSLPath], + StationLocation: m[keyExtLoTWStationLoc], + KeyPassword: m[keyExtLoTWKeyPassword], + UploadFlag: m[keyExtLoTWUploadFlag], + WriteLog: m[keyExtLoTWWriteLog] == "1", + AutoUpload: m[keyExtLoTWAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), + } + // Default the TQSL path to the standard install location when unset, so + // the field is pre-populated if TQSL is present. + if out.LoTW.TQSLPath == "" { + out.LoTW.TQSLPath = extsvc.DefaultTQSLPath() + } return out } @@ -1354,6 +1396,22 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if cfg.Clublog.AutoUpload { clAuto = "1" } + ltMode := string(extsvc.ModeImmediate) + if cfg.LoTW.UploadMode == extsvc.ModeDelayed { + ltMode = string(extsvc.ModeDelayed) + } + ltAuto := "0" + if cfg.LoTW.AutoUpload { + ltAuto = "1" + } + ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag)) + if ltFlag != "N" && ltFlag != "R" { + ltFlag = "R" + } + ltWriteLog := "0" + if cfg.LoTW.WriteLog { + ltWriteLog = "1" + } for k, v := range map[string]string{ keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)), @@ -1366,6 +1424,14 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey), keyExtClublogAutoUpload: clAuto, keyExtClublogUploadMode: clMode, + + keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath), + keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation), + keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword, + keyExtLoTWUploadFlag: ltFlag, + keyExtLoTWWriteLog: ltWriteLog, + keyExtLoTWAutoUpload: ltAuto, + keyExtLoTWUploadMode: ltMode, } { if err := a.settings.Set(a.ctx, k, v); err != nil { return err @@ -1389,6 +1455,135 @@ func (a *App) TestClublogUpload() (string, error) { return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) } +// ── QSL Manager (manual upload) ──────────────────────────────────────── + +// uploadColumnFor maps a service id to its QSO sent-status column. +func uploadColumnFor(service string) string { + switch extsvc.Service(service) { + case extsvc.ServiceQRZ: + return "qrzcom_qso_upload_status" + case extsvc.ServiceClublog: + return "clublog_qso_upload_status" + case extsvc.ServiceLoTW: + return "lotw_sent" + } + return "" +} + +// FindQSOsForUpload returns QSOs whose sent status for the given service +// matches sentStatus ("" = blank). Powers the QSL Manager's Select required. +func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) { + if a.qso == nil { + return nil, fmt.Errorf("db not initialized") + } + col := uploadColumnFor(service) + if col == "" { + return nil, fmt.Errorf("unknown service %q", service) + } + return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus))) +} + +// UploadQSOsManual uploads the given QSO ids to a service on demand +// (regardless of their current sent status — the user picked them). Runs in +// the background, emitting "qslmgr:log" lines and a final "qslmgr:done". +func (a *App) UploadQSOsManual(service string, ids []int64) error { + if a.qso == nil { + return fmt.Errorf("db not initialized") + } + svc := extsvc.Service(service) + if uploadColumnFor(service) == "" { + return fmt.Errorf("unknown service %q", service) + } + cfg := a.loadExternalServices() + go a.runManualUpload(svc, ids, cfg) + return nil +} + +func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) { + emit := func(line string) { + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:log", line) + } + } + ctx := context.Background() + uploaded := 0 + + if svc == extsvc.ServiceLoTW { + emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids))) + var recs []string + for _, id := range ids { + if rec, ok := a.buildUploadADIF(id, ""); ok { + recs = append(recs, rec) + } + } + res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n")) + if err != nil || !res.OK { + msg := res.Message + if err != nil { + msg = err.Error() + } + emit("LoTW upload failed: " + msg) + } else { + for _, id := range ids { + a.markExtUploaded(svc, id, "") + uploaded++ + } + emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded)) + } + } else { + for _, id := range ids { + q, gerr := a.qso.GetByID(ctx, id) + call := "" + if gerr == nil { + call = q.Callsign + } + force := "" + if svc == extsvc.ServiceQRZ { + force = cfg.QRZ.ForceStationCallsign + } + rec, ok := a.buildUploadADIF(id, force) + if !ok { + emit(call + " — skipped (no record)") + continue + } + var res extsvc.UploadResult + var err error + switch svc { + case extsvc.ServiceQRZ: + res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) + case extsvc.ServiceClublog: + res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) + } + if err == nil && res.OK { + a.markExtUploaded(svc, id, "") + uploaded++ + emit(call + " — OK") + } else { + msg := res.Message + if err != nil { + msg = err.Error() + } + emit(call + " — FAILED: " + msg) + } + } + } + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)}) + } +} + +// ListTQSLStationLocations returns the Station Locations defined in TQSL, +// for the LoTW settings dropdown. +func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) { + return extsvc.ListStationLocations(extsvc.DefaultStationDataPath()) +} + +// TestLoTWUpload validates the LoTW config (TQSL present + station location +// exists). +func (a *App) TestLoTWUpload() (string, error) { + return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath()) +} + // 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. @@ -1406,6 +1601,37 @@ func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) { return adif.SingleRecordADIF(q), true } +// extShouldUpload reports whether a QSO is eligible for upload to a service, +// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW +// uploads only QSOs whose lotw_sent matches the configured Upload flag +// ("N" or "R") — the Log4OM rule that must match the Confirmations default. +func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { + if a.qso == nil { + return false + } + q, err := a.qso.GetByID(a.ctx, id) + if err != nil { + return false + } + switch svc { + case extsvc.ServiceQRZ: + return !strings.EqualFold(q.QRZComUploadStatus, "Y") + case extsvc.ServiceClublog: + return !strings.EqualFold(q.ClublogUploadStatus, "Y") + case extsvc.ServiceLoTW: + flag := "R" + if a.settings != nil { + if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil { + if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" { + flag = v + } + } + } + return strings.EqualFold(q.LOTWSent, flag) + } + return false +} + // 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) { @@ -1423,6 +1649,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err) } } + case extsvc.ServiceLoTW: + if a.qso != nil { + if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil { + applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err) + } + } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c1d6595..373988b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import { Menubar, type Menu } from '@/components/Menubar'; +import { QSLManagerModal } from '@/components/QSLManagerModal'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { QSOEditModal } from '@/components/QSOEditModal'; @@ -448,6 +449,7 @@ export default function App() { // close so the next plain "Preferences" launch reverts to default. const [settingsSection, setSettingsSection] = useState(undefined); const [showDeleteAll, setShowDeleteAll] = useState(false); + const [showQSLManager, setShowQSLManager] = useState(false); const [deletingAll, setDeletingAll] = useState(false); const [ctyRefreshing, setCtyRefreshing] = useState(false); @@ -505,6 +507,17 @@ export default function App() { } }, [filterCallsign, filterBand, filterMode, qsoLimit]); + // Refresh the Recent QSOs grid after external-service uploads stamp the + // sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via + // qslmgr:done). Debounced so a batch of per-QSO events triggers one reload. + useEffect(() => { + let t: number | undefined; + const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); }; + const offUploaded = EventsOn('extsvc:uploaded', ping); + const offDone = EventsOn('qslmgr:done', ping); + return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); }; + }, [refresh]); + const loadStation = useCallback(async () => { try { setStation(await GetStationSettings()); } catch {} }, []); @@ -965,6 +978,8 @@ export default function App() { { type: 'item', label: 'Clear filters', action: 'view.clearfilters' }, ]}, { name: 'tools', label: 'Tools', items: [ + { type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' }, + { type: 'separator' }, { type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' }, { type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true }, { type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true }, @@ -989,6 +1004,7 @@ export default function App() { case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break; case 'edit.prefs': setShowSettings(true); break; + case 'tools.qslmanager': setShowQSLManager(true); break; case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break; case 'tools.refreshCty': refreshCtyDat(); break; } @@ -1979,6 +1995,8 @@ export default function App() { onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }} /> )} + setShowQSLManager(false)} /> + {deletingQSO && ( String(n).padStart(2, '0'); + return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; +} + +export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const [service, setService] = useState('lotw'); + const [sent, setSent] = useState('R'); + const [rows, setRows] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(''); + + const [logOpen, setLogOpen] = useState(false); + const [logLines, setLogLines] = useState([]); + const [uploadDone, setUploadDone] = useState(false); + + useEffect(() => { + const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); + const offDone = EventsOn('qslmgr:done', (d: any) => { + setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded —`]); + setUploadDone(true); + }); + return () => { offLog(); offDone(); }; + }, []); + + const selectedCount = selected.size; + const allSelected = rows.length > 0 && selected.size === rows.length; + + async function selectRequired() { + setSearching(true); + setError(''); + try { + const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); + const list = (r ?? []) as UploadRow[]; + setRows(list); + setSelected(new Set(list.map((x) => x.id))); // auto-select all found + } catch (e: any) { + setError(String(e?.message ?? e)); + setRows([]); + setSelected(new Set()); + } finally { + setSearching(false); + } + } + + function toggle(id: number) { + setSelected((s) => { + const n = new Set(s); + if (n.has(id)) n.delete(id); else n.add(id); + return n; + }); + } + function toggleAll() { + setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + } + + async function upload() { + const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id); + if (ids.length === 0) return; + setLogLines([]); + setUploadDone(false); + setLogOpen(true); + try { + await UploadQSOsManual(service, ids); + } catch (e: any) { + setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]); + setUploadDone(true); + } + } + + function closeLog() { + setLogOpen(false); + // Refresh the list so uploaded QSOs drop out of the current filter. + selectRequired(); + } + + const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); + + return ( + <> + { if (!o) onClose(); }}> + + + QSL Manager + Upload logged QSOs to online logbooks. + + + {/* Search toolbar */} +
+
+ + +
+
+ + +
+ +
+ + {rows.length} found · {selectedCount} selected + +
+ + {/* Results grid */} +
+ {error &&
{error}
} + {rows.length === 0 ? ( +
+ Pick a service + sent status, then “Select required”. +
+ ) : ( + + + + + + + + + + + + + + {rows.map((r) => ( + toggle(r.id)} + > + + + + + + + + + ))} + +
Date UTCCallsignBandModeCountrySent
e.stopPropagation()}> + toggle(r.id)} /> + {fmtDate(r.qso_date)}{r.callsign}{r.band}{r.mode}{r.country}{r.status || '—'}
+ )} +
+ + + + + + +
+ + {/* Upload progress / log window */} + { if (!o && uploadDone) closeLog(); }}> + + + Uploading to {serviceLabel} + Upload progress log. + +
+ {logLines.length === 0 ? ( +
starting…
+ ) : logLines.map((l, i) => ( +
{l}
+ ))} +
+ + + +
+
+ + ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b80d354..49ffe41 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,6 +18,7 @@ import { GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, + TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, } from '../../wailsjs/go/main/App'; import type { profile as profileModels } from '../../wailsjs/go/models'; @@ -154,10 +155,10 @@ const TREE: TreeNode[] = [ { kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ { kind: 'item', label: 'Station Information', id: 'station' }, - { 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' }, + { kind: 'item', label: 'Profiles', id: 'profiles' }, + { kind: 'item', label: 'Operating conditions', id: 'operating' }, + { kind: 'item', label: 'Confirmations', id: 'confirmations' }, + { kind: 'item', label: 'External services', id: 'external-services' }, ], }, { @@ -351,20 +352,27 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { type ExtServiceCfg = { api_key: string; email: string; password: string; callsign: string; force_station_callsign: string; + tqsl_path: string; station_location: string; key_password: string; + upload_flag: string; write_log: boolean; auto_upload: boolean; upload_mode: string; }; - type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ api_key: '', email: '', password: '', callsign: '', - force_station_callsign: '', auto_upload: false, upload_mode: 'immediate', + force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', + upload_flag: 'R', write_log: false, + auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ - qrz: emptyExtCfg(), clublog: emptyExtCfg(), + qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: 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); + const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [lotwTesting, setLotwTesting] = useState(false); + const [stationLocations, setStationLocations] = useState([]); // 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'); @@ -456,6 +464,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); + try { + const locs: any = await ListTQSLStationLocations(); + setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); + } catch { /* TQSL not installed — leave the dropdown empty */ } } catch (e: any) { setErr(String(e?.message ?? e)); } finally { @@ -705,12 +717,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
- updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
- updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
@@ -1796,7 +1808,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { { k: 'hrdlog', label: 'HRDLOG.NET' }, { k: 'eqsl', label: 'EQSL' }, { k: 'hamqth', label: 'HAMQTH' }, - { k: 'lotw', label: 'LOTW' }, + { k: 'lotw', label: 'LOTW', ready: true }, ]; const qrz = extSvc.qrz; const setQrz = (patch: Partial) => @@ -1834,6 +1846,33 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { } } + const lotw = extSvc.lotw; + const setLotw = (patch: Partial) => + setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } })); + + async function refreshLocations() { + try { + const locs: any = await ListTQSLStationLocations(); + setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); + } catch (e: any) { + setLotwTest({ ok: false, msg: String(e?.message ?? e) }); + } + } + + async function testLotw() { + setLotwTesting(true); + setLotwTest(null); + try { + await SaveExternalServices(extSvc as any); + const msg = await TestLoTWUpload(); + setLotwTest({ ok: true, msg }); + } catch (e: any) { + setLotwTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setLotwTesting(false); + } + } + return ( <> -
- 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. -
-
@@ -1950,11 +1983,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { /> -
- Club Log uploads each QSO in real time using your account email, password and the - logbook callsign — no API key needed for QSO upload. -
-
@@ -1990,6 +2019,80 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { + ) : extSvcTab === 'lotw' ? ( +
+
+ + setLotw({ tqsl_path: e.target.value })} + placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe" + className="font-mono text-xs" + /> + +
+ + +
+ + setLotw({ key_password: e.target.value })} + placeholder="only if your certificate key has a password" + className="text-xs" + /> + +
+ +
+ Must match your default LoTW sent status in Confirmations, or new QSOs won't be picked up. +
+
+
+ +
+ + + +
+ + {lotwTest && ( + + {lotwTest.msg} + + )} +
+
+
) : (
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b8f662c..0cc1213 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -49,6 +49,8 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise; +export function FindQSOsForUpload(arg1:string,arg2:string):Promise>; + export function GetActiveProfile():Promise; export function GetBackupSettings():Promise; @@ -91,6 +93,8 @@ export function ListProfiles():Promise>; export function ListQSO(arg1:qso.ListFilter):Promise>; +export function ListTQSLStationLocations():Promise>; + export function ListUDPIntegrations():Promise>; export function LogUDPLoggedADIF(arg1:string):Promise; @@ -159,6 +163,8 @@ export function SwitchCATRig(arg1:number):Promise; export function TestClublogUpload():Promise; +export function TestLoTWUpload():Promise; + export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function TestQRZUpload():Promise; @@ -167,4 +173,6 @@ export function TestRotator(arg1:main.RotatorSettings):Promise; export function UpdateQSO(arg1:qso.QSO):Promise; +export function UploadQSOsManual(arg1:string,arg2:Array):Promise; + export function WorkedBefore(arg1:string,arg2:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 8e82422..624ea53 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -78,6 +78,10 @@ export function ExportADIF(arg1) { return window['go']['main']['App']['ExportADIF'](arg1); } +export function FindQSOsForUpload(arg1, arg2) { + return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2); +} + export function GetActiveProfile() { return window['go']['main']['App']['GetActiveProfile'](); } @@ -162,6 +166,10 @@ export function ListQSO(arg1) { return window['go']['main']['App']['ListQSO'](arg1); } +export function ListTQSLStationLocations() { + return window['go']['main']['App']['ListTQSLStationLocations'](); +} + export function ListUDPIntegrations() { return window['go']['main']['App']['ListUDPIntegrations'](); } @@ -298,6 +306,10 @@ export function TestClublogUpload() { return window['go']['main']['App']['TestClublogUpload'](); } +export function TestLoTWUpload() { + return window['go']['main']['App']['TestLoTWUpload'](); +} + export function TestLookupProvider(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); } @@ -314,6 +326,10 @@ export function UpdateQSO(arg1) { return window['go']['main']['App']['UpdateQSO'](arg1); } +export function UploadQSOsManual(arg1, arg2) { + return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); +} + export function WorkedBefore(arg1, arg2) { return window['go']['main']['App']['WorkedBefore'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1890c26..c549ed7 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -191,6 +191,11 @@ export namespace extsvc { password: string; callsign: string; force_station_callsign: string; + tqsl_path: string; + station_location: string; + key_password: string; + upload_flag: string; + write_log: boolean; auto_upload: boolean; upload_mode: string; @@ -205,6 +210,11 @@ export namespace extsvc { this.password = source["password"]; this.callsign = source["callsign"]; this.force_station_callsign = source["force_station_callsign"]; + this.tqsl_path = source["tqsl_path"]; + this.station_location = source["station_location"]; + this.key_password = source["key_password"]; + this.upload_flag = source["upload_flag"]; + this.write_log = source["write_log"]; this.auto_upload = source["auto_upload"]; this.upload_mode = source["upload_mode"]; } @@ -212,6 +222,7 @@ export namespace extsvc { export class ExternalServices { qrz: ServiceConfig; clublog: ServiceConfig; + lotw: ServiceConfig; static createFrom(source: any = {}) { return new ExternalServices(source); @@ -221,6 +232,7 @@ export namespace extsvc { if ('string' === typeof source) source = JSON.parse(source); this.qrz = this.convertValues(source["qrz"], ServiceConfig); this.clublog = this.convertValues(source["clublog"], ServiceConfig); + this.lotw = this.convertValues(source["lotw"], ServiceConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -241,6 +253,25 @@ export namespace extsvc { return a; } } + + export class StationLocation { + name: string; + call: string; + grid: string; + dxcc: number; + + static createFrom(source: any = {}) { + return new StationLocation(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.name = source["name"]; + this.call = source["call"]; + this.grid = source["grid"]; + this.dxcc = source["dxcc"]; + } + } } @@ -1087,6 +1118,30 @@ export namespace qso { return a; } } + export class UploadRow { + id: number; + qso_date: string; + callsign: string; + band: string; + mode: string; + country: string; + status: string; + + static createFrom(source: any = {}) { + return new UploadRow(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.qso_date = source["qso_date"]; + this.callsign = source["callsign"]; + this.band = source["band"]; + this.mode = source["mode"]; + this.country = source["country"]; + this.status = source["status"]; + } + } export class WorkedEntry { id: number; // Go type: time diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 50c55f0..24331b4 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -30,7 +30,7 @@ type Service string const ( ServiceQRZ Service = "qrz" // QRZ.com Logbook ServiceClublog Service = "clublog" // Club Log real-time upload - // ServiceLoTW to come. + ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL) ) // UploadMode selects when an auto-upload fires after a QSO is saved. @@ -41,6 +41,10 @@ const ( 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 @@ -49,6 +53,7 @@ const ( // // QRZ.com → APIKey, ForceStationCallsign // Club Log → Email, Password, Callsign, APIKey +// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL) // // AutoUpload + UploadMode are common to all (timing is per-service, so the // user can run e.g. Club Log immediate and QRZ delayed). @@ -58,6 +63,11 @@ type ServiceConfig struct { Password string `json:"password"` // Club Log account password Callsign string `json:"callsign"` // Club Log logbook (owner) callsign ForceStationCallsign string `json:"force_station_callsign"` // QRZ + 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) + UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "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"` } @@ -69,17 +79,29 @@ func (c ServiceConfig) normalised() ServiceConfig { 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.TQSLPath = strings.TrimSpace(c.TQSLPath) + c.StationLocation = strings.TrimSpace(c.StationLocation) + // Upload flag is the LoTW sent-status that marks a QSO ready to upload. + // Only "N" (no) and "R" (requested) are valid; default to "R". + if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" { + c.UploadFlag = uf + } else { + c.UploadFlag = "R" + } + switch c.UploadMode { + case ModeDelayed, ModeOnClose: + // keep + default: 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"` + LoTW ServiceConfig `json:"lotw"` } // UploadResult is the outcome of a single upload attempt. diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go new file mode 100644 index 0000000..cac903d --- /dev/null +++ b/internal/extsvc/lotw.go @@ -0,0 +1,191 @@ +package extsvc + +import ( + "context" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no +// plain HTTP API — every QSO must be signed with the station certificate +// before LoTW accepts it. We write the QSO to a temporary ADIF file and run +// tqsl in batch mode to sign and upload it in one shot. + +// StationLocation is one TQSL "Station Location" the user has defined. These +// pair a callsign with a certificate + grid/zones; the upload picks one by +// name (the -l flag). +type StationLocation struct { + Name string `json:"name"` + Call string `json:"call"` + Grid string `json:"grid"` + DXCC int `json:"dxcc"` +} + +// stationDataFile mirrors TQSL's station_data XML. +type stationDataFile struct { + XMLName xml.Name `xml:"StationDataFile"` + Stations []struct { + Name string `xml:"name,attr"` + Call string `xml:"CALL"` + Grid string `xml:"GRIDSQUARE"` + DXCC int `xml:"DXCC"` + } `xml:"StationData"` +} + +// ListStationLocations parses TQSL's station_data file and returns the +// defined locations. Used to populate the Station Location dropdown — the +// same file Log4OM reads. +func ListStationLocations(stationDataPath string) ([]StationLocation, error) { + data, err := os.ReadFile(stationDataPath) + if err != nil { + return nil, fmt.Errorf("read station_data: %w", err) + } + var f stationDataFile + if err := xml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse station_data: %w", err) + } + out := make([]StationLocation, 0, len(f.Stations)) + for _, s := range f.Stations { + out = append(out, StationLocation{Name: s.Name, Call: s.Call, Grid: s.Grid, DXCC: s.DXCC}) + } + return out, nil +} + +// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or "" +// if not found. +func DefaultTQSLPath() string { + for _, p := range []string{ + `C:\Program Files (x86)\TrustedQSL\tqsl.exe`, + `C:\Program Files\TrustedQSL\tqsl.exe`, + } { + if fileExists(p) { + return p + } + } + return "" +} + +// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\ +// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set. +func DefaultStationDataPath() string { + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "TrustedQSL", "station_data") + } + return "" +} + +func fileExists(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +} + +// UploadLoTW signs and uploads one ADIF record via TQSL. tempDir is where the +// temporary .adi is written (falls back to the OS temp dir). Returns OK when +// LoTW accepts the QSO or reports it as a duplicate (already uploaded). +// +// TQSL command: +// +// tqsl -d -x -a all -l "" -u [-p ] +// +// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of +// range); 9 = some uploaded, some skipped; anything else = failure. +func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { + tqsl := strings.TrimSpace(cfg.TQSLPath) + loc := strings.TrimSpace(cfg.StationLocation) + switch { + case tqsl == "": + return UploadResult{}, fmt.Errorf("lotw: TQSL path not set") + case !fileExists(tqsl): + return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl) + case loc == "": + return UploadResult{}, fmt.Errorf("lotw: station location not set") + case strings.TrimSpace(adifRecord) == "": + return UploadResult{}, fmt.Errorf("lotw: empty adif record") + } + + // Write the QSO to a temp ADIF file (minimal header keeps strict TQSL + // happy). Cleaned up after upload. + if strings.TrimSpace(tempDir) == "" { + tempDir = os.TempDir() + } + f, err := os.CreateTemp(tempDir, "opslog-lotw-*.adi") + if err != nil { + return UploadResult{}, fmt.Errorf("lotw: create temp file: %w", err) + } + tmpPath := f.Name() + defer os.Remove(tmpPath) + if _, err := f.WriteString("OpsLog LoTW upload\nOpsLog \n" + adifRecord + "\n"); err != nil { + f.Close() + return UploadResult{}, fmt.Errorf("lotw: write temp file: %w", err) + } + f.Close() + + args := []string{"-d", "-x", "-a", "all", "-l", loc, "-u"} + if pwd := strings.TrimSpace(cfg.KeyPassword); pwd != "" { + args = append(args, "-p", pwd) + } + if cfg.WriteLog { + // -t writes a TQSL diagnostic log; drop it next to the temp ADIF. + args = append(args, "-t", filepath.Join(tempDir, "opslog-tqsl.log")) + } + args = append(args, tmpPath) + + // TQSL launches a child process and contacts LoTW — give it generous + // time, independent of any short caller deadline. + runCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + _ = ctx + + cmd := exec.CommandContext(runCtx, tqsl, args...) + out, runErr := cmd.CombinedOutput() + msg := strings.TrimSpace(string(out)) + + code := 0 + if runErr != nil { + if ee, ok := runErr.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr) + } + } + + switch code { + case 0, 9: + return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil + case 8: + return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil + default: + if msg == "" { + msg = fmt.Sprintf("tqsl exit code %d", code) + } + return UploadResult{OK: false, Message: msg}, fmt.Errorf("lotw: tqsl failed (code %d): %s", code, msg) + } +} + +// TestLoTW validates the LoTW config: tqsl present and the chosen station +// location exists in station_data. +func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) { + tqsl := strings.TrimSpace(cfg.TQSLPath) + loc := strings.TrimSpace(cfg.StationLocation) + if tqsl == "" || !fileExists(tqsl) { + return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)") + } + if loc == "" { + return "", fmt.Errorf("lotw: pick a station location") + } + locs, err := ListStationLocations(stationDataPath) + if err != nil { + return "", fmt.Errorf("lotw: can't read station locations: %w", err) + } + for _, l := range locs { + if strings.EqualFold(l.Name, loc) { + return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil + } + } + return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc) +} diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index 7a7bb4d..f14d4c2 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -4,6 +4,7 @@ import ( "context" "math/rand" "net/http" + "strings" "sync" "time" ) @@ -26,6 +27,12 @@ type Deps struct { // NotifyError surfaces a failed upload (logging + optional UI event). NotifyError func(svc Service, id int64, err error) + // ShouldUpload reports whether a QSO is eligible for upload to this + // service, based on its sent status: QRZ/Club Log upload anything not + // yet "Y"; LoTW uploads only QSOs whose lotw_sent matches the configured + // Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO. + ShouldUpload func(svc Service, id int64) bool + // Logf is an optional diagnostic logger. Logf func(format string, args ...any) } @@ -36,9 +43,10 @@ type Deps struct { type Manager struct { deps Deps - mu sync.Mutex - cfg ExternalServices - rnd *rand.Rand + mu sync.Mutex + cfg ExternalServices + rnd *rand.Rand + pending map[Service][]int64 // QSO ids queued for ModeOnClose upload } func NewManager(deps Deps) *Manager { @@ -49,7 +57,8 @@ func NewManager(deps Deps) *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())), + rnd: rand.New(rand.NewSource(time.Now().UnixNano())), + pending: map[Service][]int64{}, } } @@ -91,13 +100,30 @@ func (m *Manager) OnQSOLogged(id int64) { // QRZ.com if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" { - m.scheduleUpload(ServiceQRZ, id, qrz) + m.route(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) + m.route(ServiceClublog, id, cl) } - // LoTW will be added here. + // LoTW — needs TQSL + a station location. + if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" { + m.route(ServiceLoTW, id, lt) + } +} + +// route sends a logged QSO down the configured timing path: queue it for the +// app-close batch, or schedule an immediate / delayed upload. +func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) { + if cfg.UploadMode == ModeOnClose { + m.mu.Lock() + m.pending[svc] = append(m.pending[svc], id) + n := len(m.pending[svc]) + m.mu.Unlock() + m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n) + return + } + m.scheduleUpload(svc, id, cfg) } // scheduleUpload either uploads now (immediate) or arms a timer (delayed). @@ -111,10 +137,104 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) { 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) { +// PendingCount returns how many QSOs are queued for on-close upload across +// all services. The shutdown sequence uses it to decide whether to show the +// upload step. +func (m *Manager) PendingCount() int { + m.mu.Lock() + defer m.mu.Unlock() + n := 0 + for _, ids := range m.pending { + n += len(ids) + } + return n +} + +// FlushOnClose uploads every queued QSO. Called from the shutdown sequence. +// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a +// single TQSL batch. Returns the number of QSOs uploaded successfully. +func (m *Manager) FlushOnClose() int { + m.mu.Lock() + pending := m.pending + m.pending = map[Service][]int64{} + cfg := m.cfg + m.mu.Unlock() + + uploaded := 0 + for svc, ids := range pending { + if len(ids) == 0 { + continue + } + switch svc { + case ServiceLoTW: + uploaded += m.flushLoTWBatch(ids, cfg.LoTW) + default: + var sc ServiceConfig + switch svc { + case ServiceQRZ: + sc = cfg.QRZ + case ServiceClublog: + sc = cfg.Clublog + } + for _, id := range ids { + if m.upload(svc, id, sc) { + uploaded++ + } + } + } + } + return uploaded +} + +// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then +// stamps each as uploaded on success. +func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int { + var records []string + var kept []int64 + for _, id := range ids { + // Skip QSOs not eligible (sent status doesn't match Upload flag). + if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) { + continue + } + if rec, ok := m.deps.BuildADIF(id, ""); ok { + records = append(records, rec) + kept = append(kept, id) + } + } + if len(records) == 0 { + return 0 + } + res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n")) + if err != nil || !res.OK { + if err == nil { + err = errFromResult(res) + } + m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err) + if m.deps.NotifyError != nil { + m.deps.NotifyError(ServiceLoTW, 0, err) + } + return 0 + } + m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept)) + if m.deps.MarkUploaded != nil { + for _, id := range kept { + m.deps.MarkUploaded(ServiceLoTW, id, res.LogID) + } + } + return len(kept) +} + +// upload performs the actual push and returns true on success. 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) bool { + // Skip QSOs that aren't eligible (already sent, or sent status doesn't + // match the configured Upload flag). + if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) { + m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id) + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -126,7 +246,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) { record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign) if !ok { m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) - return + return false } res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record) case ServiceClublog: @@ -135,11 +255,19 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) { record, ok := m.deps.BuildADIF(id, "") if !ok { m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) - return + return false } res, err = UploadClublog(ctx, m.deps.Client, cfg, record) + case ServiceLoTW: + // LoTW signs the QSO's own station call via TQSL — no override. + record, ok := m.deps.BuildADIF(id, "") + if !ok { + m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) + return false + } + res, err = UploadLoTW(ctx, cfg, "", record) default: - return + return false } if err != nil || !res.OK { @@ -150,11 +278,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) { if m.deps.NotifyError != nil { m.deps.NotifyError(svc, id, err) } - return + return false } 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) } + return true } diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index e0a22fd..70238cb 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -7,6 +7,7 @@ import ( "database/sql" "errors" "fmt" + "math" "strings" "sync" "time" @@ -156,8 +157,14 @@ func normalizeNames(r *Result) { r.Name = titleCase(r.Name) r.QTH = titleCase(r.QTH) r.Address = titleCase(r.Address) + // 3 decimals (~110 m) is plenty for a contact's coordinates and keeps + // the displayed/exported value tidy. + r.Lat = round3(r.Lat) + r.Lon = round3(r.Lon) } +func round3(f float64) float64 { return math.Round(f*1000) / 1000 } + // titleCase lowercases the whole string then capitalises the first letter of // each word. Word boundaries are any non-alphanumeric rune (space, hyphen, // apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and diff --git a/internal/qso/qso.go b/internal/qso/qso.go index f8d15f3..b63a122 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -323,6 +323,51 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) { return scanQSO(row) } +// UploadRow is a lightweight QSO projection for the QSL Manager grid. +type UploadRow struct { + ID int64 `json:"id"` + QSODate string `json:"qso_date"` // ISO UTC; the UI formats it + Callsign string `json:"callsign"` + Band string `json:"band"` + Mode string `json:"mode"` + Country string `json:"country"` + Status string `json:"status"` // the matched per-service sent status +} + +// uploadStatusCols whitelists the per-service sent-status columns the QSL +// Manager may filter on (guards the dynamic column name in the query). +var uploadStatusCols = map[string]bool{ + "lotw_sent": true, + "qrzcom_qso_upload_status": true, + "clublog_qso_upload_status": true, +} + +// ListForUpload returns QSOs whose per-service sent-status column equals +// value ("" matches blank/NULL). Used by the QSL Manager's "Select required". +func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { + if !uploadStatusCols[column] { + return nil, fmt.Errorf("invalid upload column %q", column) + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, qso_date, callsign, COALESCE(band,''), COALESCE(mode,''), + COALESCE(country,''), COALESCE(`+column+`,'') + FROM qso WHERE COALESCE(`+column+`,'') = ? + ORDER BY qso_date DESC`, value) + if err != nil { + return nil, fmt.Errorf("list for upload: %w", err) + } + defer rows.Close() + var out []UploadRow + for rows.Next() { + var u UploadRow + if err := rows.Scan(&u.ID, &u.QSODate, &u.Callsign, &u.Band, &u.Mode, &u.Country, &u.Status); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + // 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 @@ -351,6 +396,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e return nil } +// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a +// successful TQSL upload. date is an ADIF YYYYMMDD string. +func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET lotw_sent = 'Y', lotw_sent_date = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`, + date, id) + if err != nil { + return fmt.Errorf("mark lotw 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 {