This commit is contained in:
2026-06-07 01:11:37 +02:00
parent 17f7a00bd7
commit 16c04fc12b
13 changed files with 418 additions and 52 deletions
+116 -21
View File
@@ -189,6 +189,7 @@ const (
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -1635,6 +1636,45 @@ func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
return out, err
}
// AwardMissingQSOs returns the contacts that fall within an award's scope but
// yield NO reference — so they're silently excluded from the award. Example:
// a French QSO (DXCC 227, in DDFM scope) whose note has no "Dxx" department.
// The operator can then open each and add the missing reference.
//
// Only awards with a DXCC scope are meaningful here: without it, "in scope" is
// the whole log, so e.g. POTA would report every non-POTA QSO. Such awards
// return an empty list (the UI explains why).
func (a *App) AwardMissingQSOs(code string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
defs := a.awardDefs()
var def *award.Def
for i := range defs {
if strings.EqualFold(defs[i].Code, code) {
def = &defs[i]
break
}
}
if def == nil {
return nil, fmt.Errorf("unknown award %q", code)
}
if len(def.DXCCFilter) == 0 {
return []qso.QSO{}, nil // not meaningful without a DXCC scope
}
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
// In the award's scope, yet no reference extracted → a gap to fix.
if award.InScope(*def, &q) && len(award.MatchQSO(*def, metas, &q)) == 0 {
out = append(out, q)
}
return nil
})
return out, err
}
// GetPOTAToken returns the stored pota.app session token (for the settings UI).
func (a *App) GetPOTAToken() string {
if a.settings == nil {
@@ -1672,6 +1712,8 @@ type POTASyncResult struct {
Added int `json:"added"` // new QSOs inserted (addMissing)
Unmatched int `json:"unmatched"` // no local QSO and not added
UnmatchedList []POTAUnmatched `json:"unmatched_list"` // per-entry detail (capped)
SkippedOtherCall int `json:"skipped_other_call"` // hunts made under another callsign (onlyMyCall)
MyCall string `json:"my_call"` // the profile call used for the onlyMyCall filter
}
// SyncPOTAHunterLog downloads the user's POTA hunter log and stamps pota_ref on
@@ -1681,7 +1723,10 @@ type POTASyncResult struct {
// (same QSO at several parks, logged within minutes) are appended.
// When addMissing is true, hunter-log entries whose callsign isn't in the log
// at all are inserted as new QSOs (callsign/date/band/mode/park, cty.dat-enriched).
func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
// onlyMyCall, when true, processes only hunts made under the active profile's
// callsign — so hunts you made under another call (e.g. XV9Q, NQ2H) that aren't
// in this logbook are skipped rather than reported as "not in your log".
func (a *App) SyncPOTAHunterLog(addMissing bool, onlyMyCall bool) (POTASyncResult, error) {
if a.qso == nil || a.settings == nil {
return POTASyncResult{}, fmt.Errorf("db not initialized")
}
@@ -1690,6 +1735,14 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
if err != nil {
return POTASyncResult{}, err
}
// The active profile's callsign drives the onlyMyCall filter (base call, so
// F4BPO/P and F4BPO are the same identity).
myCall := ""
if a.profiles != nil {
if p, perr := a.profiles.Active(a.ctx); perr == nil {
myCall = pota.BaseCall(p.Callsign)
}
}
var all []qso.QSO
if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
all = append(all, q)
@@ -1707,7 +1760,7 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
const nferWindow = 15 * time.Minute // append a 2nd park only for the same physical QSO
const maxDetail = 300
res := POTASyncResult{Fetched: len(entries)}
res := POTASyncResult{Fetched: len(entries), MyCall: myCall}
toUpdate := map[int]struct{}{}
var toAdd []pota.HunterQSO
addUnmatched := func(e pota.HunterQSO, reason string, qsoID int64) {
@@ -1724,6 +1777,12 @@ func (a *App) SyncPOTAHunterLog(addMissing bool) (POTASyncResult, error) {
}
for _, e := range entries {
// Skip hunts made under another of your callsigns (not this profile's).
// They legitimately aren't in this logbook, so don't flag them as errors.
if onlyMyCall && myCall != "" && e.Hunter != "" && pota.BaseCall(e.Hunter) != myCall {
res.SkippedOtherCall++
continue
}
if e.Date.IsZero() {
addUnmatched(e, "POTA entry has no usable date", 0)
continue
@@ -2142,7 +2201,7 @@ func bandForHz(hz int64) string {
func isComputedAwardField(field string) bool {
switch field {
// Purely derived from the callsign / cty.dat — never assigned by hand.
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid":
case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid", "grid4":
return true
}
// NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields
@@ -2796,14 +2855,21 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
im.SkipDuplicates = true
}
// When the user opts to fix countries on import, recompute from cty.dat and
// then apply ClubLog's date-ranged exceptions (which take precedence) if
// ClubLog is enabled + loaded. Unchecked = ADIF preserved verbatim.
clEnabled := a.clublogCtyEnabled() && a.clublog != nil && a.clublog.Loaded()
// then apply ClubLog's date-ranged exceptions, which take precedence (e.g.
// TO2A on 2012-10-27 → French Guiana, not the cty.dat "TO" → France). We
// apply ClubLog whenever its data is LOADED, regardless of the live
// entry-form toggle: "apply cty" is an explicit request for the most
// accurate entity, and skipping ClubLog would DOWNGRADE DXpedition QSOs the
// source ADIF already had right. If the cache isn't loaded yet, try once.
if applyCty && a.clublog != nil && !a.clublog.Loaded() {
_ = a.clublog.EnsureLoaded()
}
clLoaded := a.clublog != nil && a.clublog.Loaded()
if applyCty {
im.Enrich = func(q *qso.QSO) {
a.enrichContactedFromCtyForce(q)
if clEnabled {
a.applyClublogException(q, false)
if clLoaded {
a.applyClublogException(q, true) // force: explicit import-time correction
}
}
}
@@ -4389,17 +4455,19 @@ type ConfirmationItem struct {
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
// since controls the date window: "" = everything, "last" = incremental since
// the service's last successful download, or an explicit "YYYY-MM-DD".
func (a *App) DownloadConfirmations(service string, addNotFound bool, since string) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound)
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
@@ -4413,20 +4481,31 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
ctx := context.Background()
matched, total, added := 0, 0, 0
// resolveSince turns the UI's request into a concrete date (or ""):
// "" → all
// "last" → the service's stored last-download date (incremental)
// "date" → used verbatim (expected YYYY-MM-DD)
resolveSince := func(lastKey string) string {
s := strings.TrimSpace(since)
if strings.EqualFold(s, "last") {
if a.settings != nil {
v, _ := a.settings.Get(ctx, a.profileScope()+lastKey)
return strings.TrimSpace(v)
}
return ""
}
return s
}
switch svc {
case extsvc.ServiceLoTW:
since := ""
if a.settings != nil {
// Scoped to the active profile — each identity tracks its own
// LoTW account's last incremental-download date.
since, _ = a.settings.Get(ctx, a.profileScope()+keyExtLoTWLastDownload)
}
if since != "" {
emit("Downloading LoTW confirmations received since " + since + "…")
sinceDate := resolveSince(keyExtLoTWLastDownload)
if sinceDate != "" {
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
} else {
emit("Downloading all LoTW confirmations…")
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
@@ -4509,7 +4588,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
}
case extsvc.ServiceQRZ:
emit("Fetching QRZ.com logbook…")
// QRZ's FETCH API has no server-side date filter, so we pull the logbook
// and (when a window is requested) skip records older than sinceDate by
// QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload)
if sinceDate != "" {
emit("Fetching QRZ.com logbook (QSOs since " + sinceDate + ")…")
} else {
emit("Fetching QRZ.com logbook…")
}
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, "ALL")
if err != nil {
emit("Fetch failed: " + err.Error())
@@ -4557,6 +4644,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
if !ok {
return nil
}
// Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil
}
total++
date := rec["qrzcom_qso_download_date"]
if date == "" {
@@ -4624,6 +4715,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
sort.Strings(keys)
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
// Remember today so a later "since last download" pull is incremental.
if a.settings != nil {
_ = a.settings.Set(ctx, a.profileScope()+keyExtQRZLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))