pota
This commit is contained in:
@@ -175,6 +175,8 @@ const (
|
|||||||
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
||||||
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
||||||
|
|
||||||
|
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||||
|
|
||||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
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)
|
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
|
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"):
|
// 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
|
// distinct-reference counts per band, plus Total (distinct on any band) and
|
||||||
// GrandTotal (sum of the per-band band-slots).
|
// GrandTotal (sum of the per-band band-slots).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud,
|
Compass, Wifi, Construction, UploadCloud, Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||||
|
GetPOTAToken, SavePOTAToken, SyncPOTAHunterLog,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
@@ -481,7 +482,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// 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<mainModels.BackupSettings>({
|
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||||
enabled: false, folder: '', rotation: 5, zip: false,
|
enabled: false, folder: '', rotation: 5, zip: false,
|
||||||
@@ -2123,7 +2129,22 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL' },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
{ k: 'hamqth', label: 'HAMQTH' },
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ 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 qrz = extSvc.qrz;
|
||||||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
||||||
@@ -2437,6 +2458,37 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'pota' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
|
||||||
|
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
|
||||||
|
DevTools → <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
|
||||||
|
and copy the full <strong>Authorization</strong> header value. The token expires after a while — re-copy it if the sync fails.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
|
||||||
|
<Label className="text-sm pt-2">Session token</Label>
|
||||||
|
<Textarea
|
||||||
|
value={potaToken}
|
||||||
|
onChange={(e) => setPotaToken(e.target.value)}
|
||||||
|
placeholder="eyJ… (Authorization header from pota.app)"
|
||||||
|
className="font-mono text-[11px] h-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={syncPota} disabled={potaBusy || !potaToken.trim()}>
|
||||||
|
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Syncing…</> : 'Sync hunter log'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">Matches by callsign + band within ±5 min. Only fills QSOs without a POTA ref.</span>
|
||||||
|
</div>
|
||||||
|
{potaResult && (
|
||||||
|
<div className={cn('text-xs rounded-md px-3 py-2 border',
|
||||||
|
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
|
||||||
|
{potaResult.msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-muted-foreground">After a sync, rescan the POTA award to see the new references counted.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||||
<Construction className="size-10 opacity-30" />
|
<Construction className="size-10 opacity-30" />
|
||||||
|
|||||||
Vendored
+6
@@ -141,6 +141,8 @@ export function GetLogFilePath():Promise<string>;
|
|||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -265,6 +267,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
|
|||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
|
|
||||||
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
@@ -301,6 +305,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SyncPOTAHunterLog():Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEmail(arg1:string):Promise<void>;
|
export function TestEmail(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ export function GetLookupSettings() {
|
|||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPOTAToken() {
|
||||||
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -502,6 +506,10 @@ export function SaveOperatingStation(arg1) {
|
|||||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePOTAToken(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveProfile(arg1) {
|
export function SaveProfile(arg1) {
|
||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -574,6 +582,10 @@ export function SwitchCATRig(arg1) {
|
|||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SyncPOTAHunterLog() {
|
||||||
|
return window['go']['main']['App']['SyncPOTAHunterLog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -973,6 +973,24 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class POTASyncResult {
|
||||||
|
fetched: number;
|
||||||
|
updated: number;
|
||||||
|
already_tagged: number;
|
||||||
|
unmatched: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new POTASyncResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.fetched = source["fetched"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
this.already_tagged = source["already_tagged"];
|
||||||
|
this.unmatched = source["unmatched"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSLDefaults {
|
export class QSLDefaults {
|
||||||
qsl_sent: string;
|
qsl_sent: string;
|
||||||
qsl_rcvd: string;
|
qsl_rcvd: string;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package pota
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hunterLogURL is the authenticated POTA user logbook endpoint (paginated).
|
||||||
|
// hunterOnly=1 restricts it to the user's chaser/hunter QSOs.
|
||||||
|
const hunterLogURL = "https://api.pota.app/user/logbook?hunterOnly=1&page=%d&size=%d"
|
||||||
|
|
||||||
|
// HunterQSO is one entry from the POTA hunter log: a contact the user made with
|
||||||
|
// a park activator, carrying the park reference to stamp onto the local QSO.
|
||||||
|
type HunterQSO struct {
|
||||||
|
Worked string `json:"worked"` // activator callsign (the station worked)
|
||||||
|
Date time.Time `json:"date"` // QSO date/time (UTC)
|
||||||
|
Band string `json:"band"` // ADIF band, e.g. "20m"
|
||||||
|
Mode string `json:"mode"` // logged mode
|
||||||
|
Reference string `json:"reference"` // park ref, e.g. "US-2072"
|
||||||
|
}
|
||||||
|
|
||||||
|
// hunterEntry mirrors the POTA API logbook record (fields we use).
|
||||||
|
//
|
||||||
|
// IMPORTANT: POTA logbook entries come from the ACTIVATOR's uploaded log, so
|
||||||
|
// "station_callsign" is the activator (the park station you worked) and
|
||||||
|
// "worked_callsign" is YOU (the hunter, whom the activator worked). To match a
|
||||||
|
// local QSO — whose callsign field holds the activator — we key on
|
||||||
|
// station_callsign, NOT worked_callsign.
|
||||||
|
type hunterEntry struct {
|
||||||
|
StationCallsign string `json:"station_callsign"` // the activator (park station)
|
||||||
|
WorkedCallsign string `json:"worked_callsign"` // the hunter (you)
|
||||||
|
QSODateTime string `json:"qsoDateTime"`
|
||||||
|
Band string `json:"band"`
|
||||||
|
LoggedMode string `json:"loggedMode"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchHunterLog downloads the user's entire POTA hunter log, page by page,
|
||||||
|
// using their pota.app session token as the Authorization header. logf may be
|
||||||
|
// nil. Returns a friendly error on an expired/invalid token (HTTP 401/403).
|
||||||
|
func FetchHunterLog(ctx context.Context, token string, logf func(string, ...any)) ([]HunterQSO, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("no POTA token — paste it from pota.app (DevTools → Network → Authorization header)")
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
const size = 100
|
||||||
|
const maxPages = 5000 // safety bound
|
||||||
|
|
||||||
|
var out []HunterQSO
|
||||||
|
total := -1
|
||||||
|
for page := 1; page <= maxPages; page++ {
|
||||||
|
url := fmt.Sprintf(hunterLogURL, page, size)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POTA fetch: %w", err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("POTA rejected the token (HTTP %d) — it has likely expired; re-copy it from pota.app", resp.StatusCode)
|
||||||
|
case resp.StatusCode != http.StatusOK:
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("POTA HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Entries []hunterEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POTA decode: %w", err)
|
||||||
|
}
|
||||||
|
total = body.Count
|
||||||
|
for _, e := range body.Entries {
|
||||||
|
ref := strings.ToUpper(strings.TrimSpace(e.Reference))
|
||||||
|
// The activator is the station we worked → station_callsign.
|
||||||
|
act := strings.ToUpper(strings.TrimSpace(e.StationCallsign))
|
||||||
|
if act == "" {
|
||||||
|
act = strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)) // fallback
|
||||||
|
}
|
||||||
|
if ref == "" || act == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, HunterQSO{
|
||||||
|
Worked: act,
|
||||||
|
Date: parseHunterTime(e.QSODateTime),
|
||||||
|
Band: strings.ToLower(strings.TrimSpace(e.Band)),
|
||||||
|
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
|
||||||
|
Reference: ref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if logf != nil {
|
||||||
|
logf("pota: hunter log page %d (%d/%d)", page, len(out), total)
|
||||||
|
}
|
||||||
|
if len(body.Entries) == 0 || (total >= 0 && len(out) >= total) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHunterTime parses POTA's qsoDateTime, tolerating ISO variants (with/out
|
||||||
|
// 'T', timezone, or fractional seconds). POTA times are UTC.
|
||||||
|
func parseHunterTime(s string) time.Time {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
for _, layout := range []string{
|
||||||
|
time.RFC3339Nano, time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05.000", "2006-01-02 15:04:05", "2006-01-02 15:04:05Z",
|
||||||
|
"2006-01-02T15:04Z", "2006-01-02T15:04", "2006-01-02 15:04",
|
||||||
|
} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
@@ -123,6 +123,9 @@ func (c *Cache) Lookup(call string) (Info, bool) {
|
|||||||
return i, ok
|
return i, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BaseCall is the exported callsign normaliser used for hunter-log matching.
|
||||||
|
func BaseCall(s string) string { return baseCall(s) }
|
||||||
|
|
||||||
// baseCall normalises a callsign for matching: upper-cased, and when it carries
|
// baseCall normalises a callsign for matching: upper-cased, and when it carries
|
||||||
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
|
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
|
||||||
// almost always the home call.
|
// almost always the home call.
|
||||||
|
|||||||
Reference in New Issue
Block a user