From 922a185208ab7cc06a1e964999e85c3f2e822a88 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 6 Jun 2026 00:02:56 +0200 Subject: [PATCH] up --- app.go | 315 +++++++++++++++- frontend/src/App.tsx | 9 +- frontend/src/components/AwardEditor.tsx | 41 +- frontend/src/components/AwardRefSelector.tsx | 8 +- frontend/src/components/AwardsPanel.tsx | 374 +++++++++++++++---- frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 + frontend/wailsjs/go/models.ts | 54 +++ internal/award/award.go | 190 ++++++++-- internal/award/award_test.go | 63 ++++ 10 files changed, 941 insertions(+), 131 deletions(-) diff --git a/app.go b/app.go index 258d864..73688b9 100644 --- a/app.go +++ b/app.go @@ -520,6 +520,7 @@ func (a *App) startup(ctx context.Context) { a.settings = settings.NewStore(conn) a.profiles = profile.NewRepo(conn) a.awardRefs = awardref.NewRepo(conn) + a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) a.seedBuiltinReferences() // first-run: populate built-in award reference lists a.operating = operating.NewRepo(conn) a.udpRepo = udp.NewRepo(conn) @@ -1322,7 +1323,9 @@ func (a *App) awardDefs() []award.Def { if s, _ := a.settings.Get(a.ctx, keyAwardDefs); strings.TrimSpace(s) != "" { var defs []award.Def if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 { - return defs + // Upgrade legacy defs (pre-rich-model) in memory on every load. + migrated, _ := award.Migrate(defs) + return migrated } } } @@ -1335,6 +1338,31 @@ func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() } // AwardFields lists the scannable QSO fields for the award editor. func (a *App) AwardFields() []string { return award.Fields() } +// migrateAwardDefs upgrades legacy award definitions in storage once, so the +// editor and persisted state reflect the new model (enabled awards + filled +// matching/confirmation fields). No-op when there is nothing to migrate. +func (a *App) migrateAwardDefs() { + if a.settings == nil { + return + } + s, _ := a.settings.Get(a.ctx, keyAwardDefs) + if strings.TrimSpace(s) == "" { + return // nothing saved yet → Defaults() (already on the new model) + } + var defs []award.Def + if json.Unmarshal([]byte(s), &defs) != nil || len(defs) == 0 { + return + } + migrated, changed := award.Migrate(defs) + if !changed { + return + } + if b, err := json.Marshal(migrated); err == nil { + _ = a.settings.Set(a.ctx, keyAwardDefs, string(b)) + applog.Printf("awards: migrated %d legacy definitions to the new model", len(migrated)) + } +} + // SaveAwardDefs persists edited award definitions. func (a *App) SaveAwardDefs(defs []award.Def) error { if a.settings == nil { @@ -1356,13 +1384,41 @@ func (a *App) ResetAwardDefs() ([]award.Def, error) { return d, nil } -// GetAwards computes award progress (worked/confirmed) across the whole log. +// GetAwards computes progress for EVERY award (whole-log scan). Kept for +// callers that need all results at once; the UI now computes one award at a +// time via GetAward to stay responsive on large logs. func (a *App) GetAwards() ([]award.Result, error) { + return a.computeAwards(a.awardDefs()) +} + +// GetAward computes progress for a single award by code (one whole-log scan, +// matching only that award). This is what the awards UI calls when an award is +// selected, so opening the panel doesn't scan every award up front. +func (a *App) GetAward(code string) (award.Result, error) { + for _, d := range a.awardDefs() { + if strings.EqualFold(d.Code, code) { + results, err := a.computeAwards([]award.Def{d}) + if err != nil { + return award.Result{}, err + } + if len(results) > 0 { + return results[0], nil + } + return award.Result{}, nil + } + } + return award.Result{}, fmt.Errorf("unknown award %q", code) +} + +// computeAwards runs the engine for the given award definitions over the whole +// log and enriches dynamic awards (totals + worked-reference names). +func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) { if a.qso == nil { return nil, fmt.Errorf("db not initialized") } var all []qso.QSO if err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { + a.enrichQSOForAwards(&q) all = append(all, q) return nil }); err != nil { @@ -1379,7 +1435,6 @@ func (a *App) GetAwards() ([]award.Result, error) { } return "" } - defs := a.awardDefs() refMetas := a.awardRefMetas(defs) results := award.Compute(defs, all, refMetas, nameOf) // Dynamic awards (POTA/SOTA/…) aren't fully loaded into the engine — their @@ -1416,6 +1471,166 @@ func (a *App) GetAwards() ([]award.Result, error) { return results, nil } +// AwardCellQSOs returns the QSOs that contribute to one award reference, +// optionally on a single band (band="" = all bands). Powers the award-grid +// cell drill-down ("show me every Canada contact on 20m"). +func (a *App) AwardCellQSOs(code, ref, band 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) + } + metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] + wantRef := strings.ToUpper(strings.TrimSpace(ref)) + wantBand := strings.ToLower(strings.TrimSpace(band)) + + var out []qso.QSO + err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { + if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand { + return nil + } + a.enrichQSOForAwards(&q) + for _, c := range award.MatchQSO(*def, metas, &q) { + if strings.ToUpper(c) == wantRef { + out = append(out, q) + break + } + } + return nil + }) + return out, err +} + +// 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). +type AwardStatRow struct { + Label string `json:"label"` + Cells []int `json:"cells"` + Total int `json:"total"` + GrandTotal int `json:"grand_total"` +} + +// AwardStatsResult is the statistics matrix for one award (Log4OM "Statistics"). +type AwardStatsResult struct { + Code string `json:"code"` + Bands []string `json:"bands"` + Rows []AwardStatRow `json:"rows"` +} + +var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"} + +// GetAwardStats computes the worked/confirmed/validated reference counts of one +// award, broken down by band and by mode category (All/CW/Digital/Phone). +func (a *App) GetAwardStats(code string) (AwardStatsResult, error) { + if a.qso == nil { + return AwardStatsResult{}, 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 AwardStatsResult{}, fmt.Errorf("unknown award %q", code) + } + metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] + + bandIdx := make(map[string]int, len(statsBands)) + for i, b := range statsBands { + bandIdx[b] = i + } + cats := []string{"ALL", "CW", "DIGITAL", "PHONE"} + stats := []string{"WORKED", "CONFIRMED", "VALIDATED"} + + // acc[cat][stat]: per-band ref sets + an overall (any-band) ref set. + type acc struct { + perBand []map[string]struct{} + overall map[string]struct{} + } + accs := map[string]map[string]*acc{} + for _, c := range cats { + accs[c] = map[string]*acc{} + for _, s := range stats { + pb := make([]map[string]struct{}, len(statsBands)) + for i := range pb { + pb[i] = map[string]struct{}{} + } + accs[c][s] = &acc{perBand: pb, overall: map[string]struct{}{}} + } + } + + err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { + a.enrichQSOForAwards(&q) + refs := award.MatchQSO(*def, metas, &q) + if len(refs) == 0 { + return nil + } + bi, hasBand := bandIdx[strings.ToLower(strings.TrimSpace(q.Band))] + isConf := award.Confirmed(&q, def.Confirm) + isVal := award.Confirmed(&q, def.Validate) + cat := strings.ToUpper(award.EmissionOf(q.Mode)) + + record := func(c string) { + put := func(stat string) { + ac := accs[c][stat] + for _, r := range refs { + ac.overall[r] = struct{}{} + if hasBand { + ac.perBand[bi][r] = struct{}{} + } + } + } + put("WORKED") + if isConf { + put("CONFIRMED") + } + if isVal { + put("VALIDATED") + } + } + record("ALL") + if cat == "CW" || cat == "DIGITAL" || cat == "PHONE" { + record(cat) + } + return nil + }) + if err != nil { + return AwardStatsResult{}, err + } + + res := AwardStatsResult{Code: def.Code, Bands: statsBands} + for _, c := range cats { + for _, s := range stats { + ac := accs[c][s] + cells := make([]int, len(statsBands)) + grand := 0 + for i := range ac.perBand { + cells[i] = len(ac.perBand[i]) + grand += cells[i] + } + label := s + if c != "ALL" { + label = s + " " + c + } + res.Rows = append(res.Rows, AwardStatRow{Label: label, Cells: cells, Total: len(ac.overall), GrandTotal: grand}) + } + } + return res, nil +} + // awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false) // into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists // are large and not needed for matching; their names are filled afterwards. @@ -1459,6 +1674,85 @@ type QSOAwardRef struct { Pickable bool `json:"pickable"` } +// enrichQSOForAwards fills in CQ/ITU zone, continent and DXCC entity from +// cty.dat when the QSO doesn't carry them, so computed awards (WAZ/WITUZ/WAC/ +// DXCC) work even on records that were never enriched. Non-destructive: it +// mutates the in-memory copy used for award matching only, never the database. +func (a *App) enrichQSOForAwards(q *qso.QSO) { + if a.dxcc == nil { + return + } + // Recover the band from the frequency when missing, so per-band award + // statistics aren't lost for QSOs that carry only a frequency. + if strings.TrimSpace(q.Band) == "" && q.FreqHz != nil && *q.FreqHz > 0 { + if b := bandForHz(*q.FreqHz); b != "" { + q.Band = b + } + } + needCQ := q.CQZ == nil || *q.CQZ == 0 + needITU := q.ITUZ == nil || *q.ITUZ == 0 + needCont := strings.TrimSpace(q.Continent) == "" + needDXCC := q.DXCC == nil || *q.DXCC == 0 + if !needCQ && !needITU && !needCont && !needDXCC { + return + } + m, ok := a.dxcc.Lookup(q.Callsign) + if !ok { + return + } + if needCQ && m.CQZone > 0 { + z := m.CQZone + q.CQZ = &z + } + if needITU && m.ITUZone > 0 { + z := m.ITUZone + q.ITUZ = &z + } + if needCont && m.Continent != "" { + q.Continent = m.Continent + } + if needDXCC && m.Entity != nil { + if n := dxcc.EntityDXCC(m.Entity.Name); n > 0 { + q.DXCC = &n + } + } +} + +// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the +// band for award statistics when a QSO has a frequency but no band field. +var awardBandPlan = []struct { + name string + lo, hi int64 +}{ + {"2190m", 135700, 137800}, {"630m", 472000, 479000}, {"160m", 1800000, 2000000}, + {"80m", 3500000, 4000000}, {"60m", 5060000, 5450000}, {"40m", 7000000, 7300000}, + {"30m", 10100000, 10150000}, {"20m", 14000000, 14350000}, {"17m", 18068000, 18168000}, + {"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000}, + {"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000}, + {"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000}, + {"13cm", 2300000000, 2450000000}, +} + +func bandForHz(hz int64) string { + for _, b := range awardBandPlan { + if hz >= b.lo && hz <= b.hi { + return b.name + } + } + return "" +} + +// isComputedAwardField reports whether an award's field is auto-derived from +// structured QSO data (entity, zones, prefix, location) rather than a reference +// the operator assigns by hand. Such awards are read-only in the per-QSO editor. +func isComputedAwardField(field string) bool { + switch field { + case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid": + return true + } + return false +} + // ComputeQSOAwardRefs returns every award reference a single QSO contributes to // — manual (POTA/SOTA/IOTA/WWFF) and computed (DXCC/WAZ/WAC/WPX/DDFM/…) — for // the per-QSO Award Refs editor. Reuses the same engine as GetAwards. @@ -1474,16 +1768,21 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) { } return "" } + a.enrichQSOForAwards(&q) defs := a.awardDefs() - results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf) - var counts map[string]int - if a.awardRefs != nil { - counts, _ = a.awardRefs.Counts(a.ctx) + fieldByCode := map[string]string{} + for _, d := range defs { + fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field)) } + results := award.Compute(defs, []qso.QSO{q}, a.awardRefMetas(defs), nameOf) var out []QSOAwardRef for i := range results { r := &results[i] - pickable := counts[strings.ToUpper(r.Code)] > 0 || awardref.CanUpdate(r.Code) + // "Pickable" = the reference is manually assigned per QSO (POTA, notes…), + // NOT auto-derived from a structured field. DXCC/WAZ/WAS/WAC/WPX are + // computed and belong in the read-only panel even though they now have + // reference lists. + pickable := !isComputedAwardField(fieldByCode[strings.ToUpper(r.Code)]) for _, rf := range r.Refs { if !rf.Worked { continue // a single QSO only contributes worked references diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d2ee9a..37b1497 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -793,10 +793,13 @@ export default function App() { function applyModePreset(m: string) { if (rstUserEditedRef.current) return; + // Prefer the user's configured preset RST; otherwise fall back to the mode + // category default (CW/RTTY/PSK → 599, phone → 59, digital → first option) + // so switching SSB→CW flips 59→599 even without a configured preset. const p = modePresets.find((x) => x.name === m); - if (!p) return; - if (p.default_rst_sent) setRstSent(p.default_rst_sent); - if (p.default_rst_rcvd) setRstRcvd(p.default_rst_rcvd); + const fallback = rstOptions(m, rstLists)[0] || ''; + setRstSent(p?.default_rst_sent || fallback); + setRstRcvd(p?.default_rst_rcvd || fallback); } useEffect(() => { refresh(); }, [refresh]); diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 69b6b24..14444aa 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -13,12 +13,16 @@ import { cn } from '@/lib/utils'; import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields, GetAwardReferenceMeta, UpdateAwardReferenceList, - ListAwardReferences, SaveAwardReference, DeleteAwardReference, + ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, } from '../../wailsjs/go/main/App'; +// Above this many references the editor stops loading the whole list and +// switches to search-only (mirrors Log4OM's "Too many items" behaviour). +const REF_LIST_CAP = 1000; + type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean }; export type AwardDef = { @@ -358,24 +362,44 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan const [showBulk, setShowBulk] = useState(false); const [hasBuiltin, setHasBuiltin] = useState(false); + const total = meta?.count ?? 0; + const large = total > REF_LIST_CAP; + + // Small lists are loaded whole and filtered client-side; large lists (POTA, + // 85k parks) are search-only to stay responsive. const load = () => { if (!code) return; + if (total > REF_LIST_CAP) { setRefs([]); return; } setBusy(true); ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false)); }; - useEffect(load, [code]); + useEffect(load, [code, total]); useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]); + // Server-side search for large lists (debounced, min 2 chars). + useEffect(() => { + if (!large) return; + const s = q.trim(); + if (s.length < 2) { setRefs([]); return; } + const t = window.setTimeout(() => { + setBusy(true); + SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false)); + }, 200); + return () => window.clearTimeout(t); + }, [code, q, large]); + async function populateBuiltin() { try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); } catch (e: any) { setErr(String(e?.message ?? e)); } } const sel = refs.find((r) => r.code === selCode) || null; + // Large lists are already filtered by the server; small lists filter locally. const filtered = useMemo(() => { + if (large) return refs; const s = q.trim().toUpperCase(); return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s)); - }, [refs, q]); + }, [refs, q, large]); const patchSel = (p: Partial) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r))); @@ -407,7 +431,7 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
{/* Toolbar */}
- Reference count: {refs.length} + Reference count: {total.toLocaleString()}
setQ(e.target.value)} />
- {busy &&
Loading…
} - {!busy && filtered.length === 0 &&
No references.
} + {busy &&
Searching…
} + {!busy && large && q.trim().length < 2 && ( +
+ Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters). +
+ )} + {!busy && filtered.length === 0 && !(large && q.trim().length < 2) &&
No references.
} {filtered.map((r) => ( -
- setEditing(false)} onSaved={load} /> + setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
{err &&
{err}
} - {results.map((r) => ( - - ))} + {awardList.map((a) => { + const r = byCode[a.code]; + return ( + + ); + })}
@@ -146,55 +221,202 @@ export function AwardsPanel() {
)} - {/* References table */} -
+ {/* References toolbar */} +
setRefSearch(e.target.value)} />
- {filteredRefs.length} reference{filteredRefs.length > 1 ? 's' : ''} +
+ {([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => ( + + ))} +
+ {filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''} +
+ {/* Legend */} +
+ W + C + V +
+
+ + + +
-
- - - - - - - - - - - - {filteredRefs.map((r) => ( - - - - - - + + {/* Statistics matrix: status × band, by mode category */} + {view === 'stats' ? ( +
+ {statsLoading || !stats ? ( +
Computing…
+ ) : ( +
RefNameGroupStatusBands
{r.ref}{r.name}{r.group} - {!r.worked ? ( - — missing - ) : r.validated ? ( - valid. - ) : r.confirmed ? ( - conf. - ) : ( - worked - )} - - {r.bands.map((b) => ( - {b} - ))} -
+ + + + {stats.bands.map((b) => )} + + + + + + {stats.rows.map((row, i) => { + const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label); + return ( + 0 && 'border-t-2 border-border')}> + + {row.cells.map((c, j) => ( + + ))} + + + + ); + })} + +
Statistic{b}TotalGrand
{row.label} 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}{row.total || ''}{row.grand_total || ''}
+ )} +
+ ) : view === 'grid' ? ( +
+ + + + + + {GRID_BANDS.map((b) => ( + + ))} - ))} - -
RefDescription{b}
-
+ + + {filteredRefs.map((r) => ( + + {r.ref} + + {r.name}{r.group ? · {r.group} : ''} + + {GRID_BANDS.map((b) => { + const s = cellStatus(r, b); + return ( + + {s === 'none' ? : ( + + )} + + ); + })} + + ))} + + +
+ ) : ( +
+ + + + + + + + + + + + {filteredRefs.map((r) => ( + + + + + + + + ))} + +
RefNameGroupStatusBands
{r.ref}{r.name}{r.group} + {!r.worked ? — missing + : r.validated ? validated + : r.confirmed ? confirmed + : worked} + + {r.bands.map((b) => ( + {b} + ))} +
+
+ )} )}
+ + {cell && current && ( + setCell(null)} /> + )} + + ); +} + +// CellQSOModal lists the QSOs behind one award-grid cell (reference × band). +function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) { + const [qsos, setQsos] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + setLoading(true); + AwardCellQSOs(code, cell.ref, cell.band) + .then((r) => setQsos((r ?? []) as any)) + .catch(() => setQsos([])) + .finally(() => setLoading(false)); + }, [code, cell.ref, cell.band]); + + const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; + + return ( +
+
e.stopPropagation()}> +
+ {code} · {cell.ref} · {cell.band} + {cell.name && {cell.name}} +
+ +
+
+ {loading ? ( +
Loading…
+ ) : qsos.length === 0 ? ( +
No QSOs.
+ ) : ( + + + + + + {qsos.map((q, i) => ( + + + + + + + + ))} + +
Date (UTC)CallsignBandModeQSL
{fmt(q.qso_date)}{q.callsign}{q.band}{q.mode}{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}
+ )} +
+
{qsos.length} QSO{qsos.length > 1 ? 's' : ''}
+
); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9706f88..24ee5fc 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -21,6 +21,8 @@ export function AddQSO(arg1:qso.QSO):Promise; export function ApplyAwardPreset(arg1:string,arg2:string):Promise; +export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise>; + export function AwardFields():Promise>; export function ClearLookupCache():Promise; @@ -97,12 +99,16 @@ export function GetActiveProfile():Promise; export function GetAudioSettings():Promise; +export function GetAward(arg1:string):Promise; + export function GetAwardDefs():Promise>; export function GetAwardPresets():Promise>; export function GetAwardReferenceMeta():Promise>; +export function GetAwardStats(arg1:string):Promise; + export function GetAwards():Promise>; export function GetBackupSettings():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 636079d..acca4ee 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -14,6 +14,10 @@ export function ApplyAwardPreset(arg1, arg2) { return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); } +export function AwardCellQSOs(arg1, arg2, arg3) { + return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); +} + export function AwardFields() { return window['go']['main']['App']['AwardFields'](); } @@ -166,6 +170,10 @@ export function GetAudioSettings() { return window['go']['main']['App']['GetAudioSettings'](); } +export function GetAward(arg1) { + return window['go']['main']['App']['GetAward'](arg1); +} + export function GetAwardDefs() { return window['go']['main']['App']['GetAwardDefs'](); } @@ -178,6 +186,10 @@ export function GetAwardReferenceMeta() { return window['go']['main']['App']['GetAwardReferenceMeta'](); } +export function GetAwardStats(arg1) { + return window['go']['main']['App']['GetAwardStats'](arg1); +} + export function GetAwards() { return window['go']['main']['App']['GetAwards'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c67cc59..19b7ad9 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -164,6 +164,7 @@ export namespace award { validated: boolean; bands: string[]; confirmed_bands: string[]; + validated_bands: string[]; static createFrom(source: any = {}) { return new Ref(source); @@ -180,6 +181,7 @@ export namespace award { this.validated = source["validated"]; this.bands = source["bands"]; this.confirmed_bands = source["confirmed_bands"]; + this.validated_bands = source["validated_bands"]; } } export class Result { @@ -676,6 +678,58 @@ export namespace main { this.can_update = source["can_update"]; } } + export class AwardStatRow { + label: string; + cells: number[]; + total: number; + grand_total: number; + + static createFrom(source: any = {}) { + return new AwardStatRow(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.label = source["label"]; + this.cells = source["cells"]; + this.total = source["total"]; + this.grand_total = source["grand_total"]; + } + } + export class AwardStatsResult { + code: string; + bands: string[]; + rows: AwardStatRow[]; + + static createFrom(source: any = {}) { + return new AwardStatsResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.code = source["code"]; + this.bands = source["bands"]; + this.rows = this.convertValues(source["rows"], AwardStatRow); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class BackupSettings { enabled: boolean; folder: string; diff --git a/internal/award/award.go b/internal/award/award.go index 1fbc67a..bcce669 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -101,6 +101,48 @@ func Defaults() []Def { } } +// Migrate upgrades award definitions saved before the richer model existed. +// Such defs have Type=="" and the zero value for the new fields (notably +// Valid==false, which would otherwise hide every legacy award). For each legacy +// def it enables the award, fills the matching/confirmation fields from the +// matching built-in default (preserving the user's field/filters/confirm), and +// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and +// whether anything changed. Idempotent: a def with Type!="" is left untouched. +func Migrate(defs []Def) ([]Def, bool) { + defaults := map[string]Def{} + for _, d := range Defaults() { + defaults[strings.ToUpper(d.Code)] = d + } + const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b` + changed := false + out := make([]Def, len(defs)) + for i, d := range defs { + if d.Type != "" { + out[i] = d // already on the new model + continue + } + changed = true + d.Valid = true // legacy defs predate the Valid flag → enable them + if def, ok := defaults[strings.ToUpper(d.Code)]; ok { + d.Type = def.Type + d.ExactMatch = def.ExactMatch + d.Dynamic = def.Dynamic + d.Protected = def.Protected + if len(d.Validate) == 0 { + d.Validate = def.Validate + } + // Fix DDFM's capture group ("06" → "D06") so refs match the list. + if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) { + d.Pattern = def.Pattern + } + } else { + d.Type = TypeQSOFields // sensible default for custom legacy awards + } + out[i] = d + } + return out, changed +} + // Fields lists the scannable QSO fields for the award editor. func Fields() []string { return []string{ @@ -129,6 +171,7 @@ type Ref struct { Validated bool `json:"validated"` Bands []string `json:"bands"` ConfirmedBands []string `json:"confirmed_bands"` + ValidatedBands []string `json:"validated_bands"` } // Result is an award's computed progress. @@ -151,6 +194,7 @@ type NameResolver func(field, ref string) string type refAgg struct { bands map[string]struct{} confirmedBands map[string]struct{} + validatedBands map[string]struct{} anyConfirmed bool anyValidated bool } @@ -158,8 +202,9 @@ type refAgg struct { // refList is the per-award reference data Compute needs (a thin view of // awardref.Ref, kept local so the award package stays storage-agnostic). type refList struct { - byCode map[string]RefMeta // uppercased code → metadata - codes []string // codes in input order (for stable unworked listing) + byCode map[string]RefMeta // uppercased code → metadata + codes []string // codes in input order (for stable unworked listing) + withPattern []string // codes whose reference declares a regex (usually none) } // RefMeta is one reference's metadata for the engine: enough to enforce a @@ -187,6 +232,7 @@ func NewRefList(metas []RefMeta) refList { if p := strings.TrimSpace(m.Pattern); p != "" { if re, err := regexp.Compile(p); err == nil { m.re = re + rl.withPattern = append(rl.withPattern, code) } } m.Code = code @@ -245,7 +291,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N for _, ref := range refs { a := agg[i][ref] if a == nil { - a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}} + a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}} agg[i][ref] = a } if band != "" { @@ -259,6 +305,9 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N } if isVal { a.anyValidated = true + if band != "" { + a.validatedBands[band] = struct{}{} + } } } } @@ -281,7 +330,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N r.Validated++ } rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated, - Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)} + Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)} labelRef(&rf, d, ref, rl, hasList, nameOf) r.Refs = append(r.Refs, rf) for b := range a.bands { @@ -303,7 +352,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N if !m.Valid { continue } - rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}} + rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}} if rf.Name == "" && nameOf != nil { rf.Name = nameOf(d.Field, code) } @@ -317,7 +366,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N if r.Refs[a].Confirmed != r.Refs[b].Confirmed { return r.Refs[a].Confirmed } - return r.Refs[a].Ref < r.Refs[b].Ref + return natLess(r.Refs[a].Ref, r.Refs[b].Ref) }) for _, b := range sortedBands(bandWorked) { r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]}) @@ -327,6 +376,32 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N return out } +// MatchQSO returns the reference codes a single QSO contributes to for one +// award (respecting scope + predefined enforcement). metas is the award's +// reference list (empty/nil for dynamic awards). Used for cell drill-down. +func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string { + if !inScope(&d, q) { + return nil + } + var re *regexp.Regexp + if p := strings.TrimSpace(d.Pattern); p != "" { + if c, err := regexp.Compile(p); err == nil { + re = c + } else { + return nil + } + } + rl := NewRefList(metas) + return candidates(&d, re, q, rl, len(metas) > 0) +} + +// Confirmed reports whether a QSO satisfies any of the given confirmation +// sources (lotw|qsl|eqsl). Exported for the statistics view. +func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) } + +// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL). +func EmissionOf(mode string) string { return emissionOf(mode) } + // labelRef fills a worked reference's name/group from the reference list (or the // name resolver as a fallback). func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) { @@ -355,12 +430,16 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) // Award-level regex: capture group 1 (or whole match) for each hit. found = regexTokens(re, raw) case predefined && !d.ExactMatch: - // "Search reference inside the field": keep any reference code that - // appears as a token (or whose per-reference pattern matches). - up := strings.ToUpper(raw) - for _, code := range rl.codes { - m := rl.byCode[code] - if (m.re != nil && m.re.MatchString(raw)) || containsToken(up, code) { + // "Search reference inside the field": look up each token of the field in + // the list — O(tokens), not O(all references) — plus test the few + // references that declare a regex. + for _, tok := range tokenize(raw) { + if _, ok := rl.byCode[tok]; ok { + found = append(found, tok) + } + } + for _, code := range rl.withPattern { + if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) { found = append(found, code) } } @@ -372,8 +451,11 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) if !predefined { return dedupe(found) } - // Enforce the predefined list: keep only listed, valid references whose - // per-reference DXCC scope (if any) includes the QSO's entity. + // Enforce the predefined list: keep only listed, valid references. The + // award-level DXCCFilter already scopes which QSOs are considered (see + // inScope), so we do NOT additionally require the QSO's entity to match the + // reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is + // DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker. var out []string seen := map[string]struct{}{} for _, c := range found { @@ -382,9 +464,6 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) if !ok || !m.Valid { continue } - if len(m.DXCCList) > 0 && !dxccAllowed(q.DXCC, m.DXCCList) { - continue - } if _, dup := seen[c]; dup { continue } @@ -425,27 +504,28 @@ func dedupe(in []string) []string { return out } -// containsToken reports whether code appears in up (already uppercased) as a -// whole token (delimited by non-alphanumerics), so "D6" doesn't match "D60". -func containsToken(up, code string) bool { - for from := 0; ; { - idx := strings.Index(up[from:], code) - if idx < 0 { - return false - } - i := from + idx - j := i + len(code) - leftOK := i == 0 || !isAlnum(up[i-1]) - rightOK := j == len(up) || !isAlnum(up[j]) - if leftOK && rightOK { - return true - } - from = i + 1 +// tokenize splits a field into uppercased tokens on any non-alphanumeric run, +// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553"). +// The whole trimmed value is also returned so single-token fields match. +func tokenize(raw string) []string { + up := strings.ToUpper(strings.TrimSpace(raw)) + if up == "" { + return nil } -} - -func isAlnum(b byte) bool { - return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') + out := []string{up} + cur := strings.Builder{} + for _, r := range up { + if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' { + cur.WriteRune(r) + } else if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out } // stripAffix removes a leading and/or trailing literal string before matching. @@ -462,6 +542,42 @@ func stripAffix(s, lead, trail string) string { func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) } +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +// natLess is a natural ("human") comparison: digit runs compare as numbers, so +// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10". +func natLess(a, b string) bool { + ia, ib := 0, 0 + for ia < len(a) && ib < len(b) { + ca, cb := a[ia], b[ib] + if isDigit(ca) && isDigit(cb) { + ja, jb := ia, ib + for ja < len(a) && isDigit(a[ja]) { + ja++ + } + for jb < len(b) && isDigit(b[jb]) { + jb++ + } + na := strings.TrimLeft(a[ia:ja], "0") + nb := strings.TrimLeft(b[ib:jb], "0") + if len(na) != len(nb) { + return len(na) < len(nb) // fewer digits = smaller number + } + if na != nb { + return na < nb + } + ia, ib = ja, jb + } else { + if ca != cb { + return ca < cb + } + ia++ + ib++ + } + } + return len(a)-ia < len(b)-ib +} + // inScope reports whether a QSO falls within an award's scope (DXCC entity, // bands, modes, emission category, validity dates). func inScope(d *Def, q *qso.QSO) bool { diff --git a/internal/award/award_test.go b/internal/award/award_test.go index 9502e85..756e13e 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -1,6 +1,7 @@ package award import ( + "sort" "testing" "hamlog/internal/qso" @@ -66,6 +67,17 @@ func TestComputeDXCCAndConfirm(t *testing.T) { } } +func TestNatLess(t *testing.T) { + in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"} + want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"} + sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) }) + for i := range want { + if in[i] != want[i] { + t.Fatalf("natLess order = %v, want %v", in, want) + } + } +} + func refCodes(r Result) []string { out := make([]string, 0, len(r.Refs)) for _, rf := range r.Refs { @@ -74,6 +86,57 @@ func refCodes(r Result) []string { return out } +// Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded. +func TestMigrate(t *testing.T) { + legacy := []Def{ + {Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type="" + {Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96}, + {Code: "MYAWARD", Field: "note"}, // custom legacy + } + out, changed := Migrate(legacy) + if !changed { + t.Fatal("expected migration to change legacy defs") + } + for _, d := range out { + if !d.Valid { + t.Errorf("%s should be enabled after migration", d.Code) + } + if d.Type == "" { + t.Errorf("%s should have a type after migration", d.Code) + } + } + if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` { + t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern) + } + if out[2].Type != TypeQSOFields { + t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type) + } + // Idempotent: a second pass changes nothing. + if _, changed2 := Migrate(out); changed2 { + t.Error("migration should be idempotent") + } +} + +// Regression: a predefined reference whose own DXCC differs from the QSO's +// entity must still count when the field code matches and the award-level +// DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291). +func TestComputePredefinedCrossDXCC(t *testing.T) { + def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true, + DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true} + qsos := []qso.QSO{ + {Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska + {Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // continental + } + refMetas := map[string][]RefMeta{"WAS": { + {Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose + {Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true}, + }} + r := Compute([]Def{def}, qsos, refMetas, nil)[0] + if r.Worked != 2 { + t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r)) + } +} + // A predefined award only counts references present in its list, lists the // unworked ones too, and uses the list size as the denominator. func TestComputePredefinedList(t *testing.T) {