awards
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user