This commit is contained in:
2026-06-06 01:43:27 +02:00
parent b4e104f5a2
commit 176cc0e62b
7 changed files with 323 additions and 2 deletions
+98
View File
@@ -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).