diff --git a/app.go b/app.go index 37416d5..c399a5c 100644 --- a/app.go +++ b/app.go @@ -175,6 +175,8 @@ const ( keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" keyExtClublogUploadMode = "extsvc.clublog.upload_mode" + keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync + keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) @@ -1550,6 +1552,102 @@ func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) { return out, err } +// GetPOTAToken returns the stored pota.app session token (for the settings UI). +func (a *App) GetPOTAToken() string { + if a.settings == nil { + return "" + } + t, _ := a.settings.Get(a.ctx, keyExtPotaToken) + return t +} + +// SavePOTAToken stores the pota.app session token used to sync the hunter log. +func (a *App) SavePOTAToken(token string) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + return a.settings.Set(a.ctx, keyExtPotaToken, strings.TrimSpace(token)) +} + +// POTASyncResult summarises a hunter-log sync run for the UI. +type POTASyncResult struct { + Fetched int `json:"fetched"` // hunter-log entries downloaded + Updated int `json:"updated"` // QSOs newly stamped with a park ref + AlreadyTagged int `json:"already_tagged"` // matched but already had a pota_ref + Unmatched int `json:"unmatched"` // no local QSO matched +} + +// SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on +// matching local QSOs (same callsign + band within ±5 min), filling only QSOs +// that don't already carry a park reference. +func (a *App) SyncPOTAHunterLog() (POTASyncResult, error) { + if a.qso == nil || a.settings == nil { + return POTASyncResult{}, fmt.Errorf("db not initialized") + } + token, _ := a.settings.Get(a.ctx, keyExtPotaToken) + entries, err := pota.FetchHunterLog(a.ctx, token, applog.Printf) + if err != nil { + return POTASyncResult{}, err + } + var all []qso.QSO + if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { + all = append(all, q) + return nil + }); err != nil { + return POTASyncResult{}, err + } + idx := map[string][]int{} + for i := range all { + idx[potaMatchKey(all[i].Callsign, all[i].Band)] = append(idx[potaMatchKey(all[i].Callsign, all[i].Band)], i) + } + + const window = 5 * time.Minute + res := POTASyncResult{Fetched: len(entries)} + toUpdate := map[int]struct{}{} + for _, e := range entries { + if e.Date.IsZero() { + res.Unmatched++ + continue + } + best, bestEmpty, found := -1, false, false + var bestDiff time.Duration + for _, i := range idx[potaMatchKey(e.Worked, e.Band)] { + diff := all[i].QSODate.Sub(e.Date) + if diff < 0 { + diff = -diff + } + if diff > window { + continue + } + found = true + if best < 0 || diff < bestDiff { + best, bestDiff, bestEmpty = i, diff, all[i].POTARef == "" + } + } + switch { + case !found: + res.Unmatched++ + case !bestEmpty: + res.AlreadyTagged++ + default: + all[best].POTARef = e.Reference // also prevents re-using this QSO + toUpdate[best] = struct{}{} + res.Updated++ + } + } + for i := range toUpdate { + _ = a.qso.Update(a.ctx, all[i]) + } + applog.Printf("pota: hunter-log sync — %d fetched, %d updated, %d already, %d unmatched", + res.Fetched, res.Updated, res.AlreadyTagged, res.Unmatched) + return res, nil +} + +// potaMatchKey indexes a QSO by base callsign + band for hunter-log matching. +func potaMatchKey(call, band string) string { + return pota.BaseCall(call) + "|" + strings.ToLower(strings.TrimSpace(band)) +} + // AwardStatRow is one row of the award statistics matrix (e.g. "CONFIRMED CW"): // distinct-reference counts per band, plus Total (distinct on any band) and // GrandTotal (sum of the per-band band-slots). diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 7330399..04410cd 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, UploadCloud, + Compass, Wifi, Construction, UploadCloud, Loader2, } from 'lucide-react'; import { GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, @@ -24,6 +24,7 @@ import { GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase, GetQSLDefaults, SaveQSLDefaults, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, + GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, } from '../../wailsjs/go/main/App'; @@ -481,7 +482,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { 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'); + const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz'); + // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). + const [potaToken, setPotaToken] = useState(''); + const [potaBusy, setPotaBusy] = useState(false); + const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string } | null>(null); + useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []); const [backupCfg, setBackupCfg] = useState({ enabled: false, folder: '', rotation: 5, zip: false, @@ -2123,7 +2129,22 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { { k: 'eqsl', label: 'EQSL' }, { k: 'hamqth', label: 'HAMQTH' }, { k: 'lotw', label: 'LOTW', ready: true }, + { k: 'pota', label: 'POTA', ready: true }, ]; + + async function syncPota() { + setPotaBusy(true); + setPotaResult(null); + try { + await SavePOTAToken(potaToken); + const r: any = await SyncPOTAHunterLog(); + setPotaResult({ ok: true, msg: `${r.updated} QSO updated · ${r.already_tagged} already tagged · ${r.unmatched} unmatched (of ${r.fetched} hunter-log entries).` }); + } catch (e: any) { + setPotaResult({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setPotaBusy(false); + } + } const qrz = extSvc.qrz; const setQrz = (patch: Partial) => setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } })); @@ -2437,6 +2458,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { + ) : extSvcTab === 'pota' ? ( +
+

+ Update your QSOs with the park reference from your pota.app hunter log. + Paste your session token: log in at pota.app, open the browser + DevTools → Network tab, click any api.pota.app request, + and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails. +

+
+ +