diff --git a/app.go b/app.go
index f478ac2..b9bb6fb 100644
--- a/app.go
+++ b/app.go
@@ -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))
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 59fbda1..a83f8f1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2706,7 +2706,7 @@ export default function App() {
-
+
@@ -2940,7 +2940,7 @@ export default function App() {
Fix country & zones (cty.dat + ClubLog)
- Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). If ClubLog exceptions are enabled, its date-ranged DXpedition overrides are applied on top (per QSO date). Everything else in the ADIF is kept as-is.
+ Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.
diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx
index 23e1a47..15de1e1 100644
--- a/frontend/src/components/AwardEditor.tsx
+++ b/frontend/src/components/AwardEditor.tsx
@@ -44,7 +44,11 @@ type AwardRef = {
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
-const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
+// Award types mirror Log4OM: REFERENCE (we supply a list), QSOFIELDS (scan any
+// QSO field — handles state/grid4/zones/etc.), CALLSIGN. The type is
+// organizational only; matching is driven by the field/pattern/dynamic options,
+// so there's no need for separate GRID/DXCC types (use QSOFIELDS + the field).
+const AWARD_TYPES = ['REFERENCE', 'QSOFIELDS', 'CALLSIGN'];
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
@@ -141,6 +145,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [updating, setUpdating] = useState(null);
const [err, setErr] = useState('');
+ // The err banner doubles as a success/notice area (export path, import counts,
+ // "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
+ // forever; the longer text (export path) gets a bit more time.
+ useEffect(() => {
+ if (!err) return;
+ const t = window.setTimeout(() => setErr(''), 8000);
+ return () => window.clearTimeout(t);
+ }, [err]);
+
const loadMeta = () => GetAwardReferenceMeta()
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
.catch(() => {});
@@ -214,7 +227,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
- return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
+ // Keep the original index `i` (used for selection/patch) but display the
+ // list sorted alphabetically by code — scales as the catalogue grows.
+ return defs.map((d, i) => ({ d, i }))
+ .filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q))
+ .sort((a, b) => a.d.code.localeCompare(b.d.code));
}, [defs, search]);
return (
@@ -251,7 +268,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */}
+ In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
+ {onEditQSO && ' Click a row to open the QSO and add the reference.'}
+
+
+ {loading ? (
+
Scanning…
+ ) : qsos.length === 0 ? (
+
+ No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)
+
+ ) : (
+
+
+
Date (UTC)
Callsign
Band
Mode
Country
QTH / Note
+
+
+ {qsos.map((q, i) => (
+
onEditQSO && q.id && onEditQSO(q.id as number)}>
+