feat: added FFMA award support

This commit is contained in:
2026-07-13 22:18:25 +02:00
parent f5ffe81c72
commit 08f4b61523
7 changed files with 4778 additions and 19 deletions
+44 -11
View File
@@ -2157,6 +2157,7 @@ func (a *App) awardDefs() []award.Def {
if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 {
// Upgrade legacy defs (pre-rich-model) in memory on every load.
migrated, _ := award.Migrate(defs)
migrated, _ = mergeCatalog(migrated)
return migrated
}
}
@@ -2164,6 +2165,27 @@ func (a *App) awardDefs() []award.Def {
return award.Defaults()
}
// mergeCatalog adds the catalog awards that are missing from the stored
// definitions. This is how an award SHIPPED in a new release (FFMA, say) reaches
// an operator who already has awards saved: without it awardDefs() would keep
// returning the stored copy and the new award would simply never appear. Add-only
// — an award already there keeps the operator's edits, Valid=false included.
func mergeCatalog(defs []award.Def) ([]award.Def, bool) {
have := make(map[string]struct{}, len(defs))
for _, d := range defs {
have[strings.ToUpper(strings.TrimSpace(d.Code))] = struct{}{}
}
added := false
for _, d := range award.Defaults() {
if _, ok := have[strings.ToUpper(strings.TrimSpace(d.Code))]; ok {
continue
}
defs = append(defs, d)
added = true
}
return defs, added
}
// GetAwardDefs returns the (editable) award definitions.
func (a *App) GetAwardDefs() []award.Def { return a.awardDefs() }
@@ -2189,6 +2211,11 @@ func (a *App) migrateAwardDefs() {
return
}
migrated, changed := award.Migrate(defs)
// Awards added to the catalog since this operator last saved (a new release
// ships one) must be written back, or the editor would keep showing the old set.
if merged, added := mergeCatalog(migrated); added {
migrated, changed = merged, true
}
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
@@ -3825,8 +3852,10 @@ func freeAwardCode(code string, taken map[string]int) string {
const builtinRefsVersion = "2"
// seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored): seed only awards that have NO references
// yet, so an online-loaded list (POTA…) or a user list isn't clobbered.
// - First run (no version stored), or already up to date: seed only awards that
// have NO references yet, so an online-loaded list (POTA…), a user list, or an
// edited one isn't clobbered. This is also what gives an award newly added to
// the catalog (FFMA and its 488 grids) its list on an existing install.
// - Version bump (stored != current): RE-SEED the derived built-in lists
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
// These lists are canonical, not user-maintained, so overwriting is safe.
@@ -3834,22 +3863,26 @@ func (a *App) seedBuiltinReferences() {
if a.awardRefs == nil || a.settings == nil {
return
}
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
if ver == builtinRefsVersion {
return
}
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
if ver == "1" {
firstRun = false // already seeded once → treat as a version upgrade
}
counts, err := a.awardRefs.Counts(a.ctx)
if err != nil {
return
}
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
if ver == "1" {
firstRun = false // already seeded once → treat as a version upgrade
}
if ver == builtinRefsVersion {
// Already on the current data. But an award ADDED to the catalog since the
// last run (FFMA, say) still has an EMPTY list, and bumping the version to
// seed it would re-seed every award and clobber lists the operator has
// edited. So fill only the empty ones.
firstRun = true
}
for _, d := range a.awardDefs() {
code := strings.ToUpper(d.Code)
if firstRun && counts[code] > 0 {
continue // don't overwrite an existing list on a fresh install
continue // don't overwrite an existing list
}
// A catalog award that SHIPS its own reference list wins: that is how an
// award added as a JSON file (a shared WAPC, with its provinces and their
+6 -8
View File
@@ -355,14 +355,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
{/* "Built-in" is what you tick before dropping an award into the
shipped catalog. Leave it off and "Reset to defaults" DELETES
the award on the user's machine — even though you shipped it.
Editing the JSON by hand to fix that is exactly what we're
avoiding here. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
</label>
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
the catalog derives that on load. Asking the author to tick a
box to declare it would be one more step nobody can guess —
forget it and the award silently misses every future catalog
correction. "Protected" stays: whether an award can be deleted
IS a real choice. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
</label>
+8
View File
@@ -200,6 +200,14 @@ func Catalog() []CatalogEntry {
}
}
}
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
// trusting the flag in the file: you drop in an award you exported, its JSON
// says builtin:false (you wrote it, it wasn't built-in then), and it would
// quietly miss every future catalog correction. Making the author remember to
// flip a flag is exactly the kind of step nobody can guess.
for i := range out {
out[i].Def.Builtin = true
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
return out
}
+87
View File
@@ -3,7 +3,9 @@ package award
import (
"encoding/json"
"sort"
"strings"
"testing"
"time"
"hamlog/internal/qso"
)
@@ -287,6 +289,71 @@ func TestComputeGrid4VUCC(t *testing.T) {
}
}
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
// fails on the count, the catalog file is wrong, not the test.
func TestCatalogFFMA(t *testing.T) {
raw, ok := CatalogRefs("FFMA")
if !ok {
t.Fatal("FFMA has no reference list in the embedded catalog")
}
var refs []struct {
Code string `json:"code"`
DXCC int `json:"dxcc"`
Valid bool `json:"valid"`
}
if err := json.Unmarshal(raw, &refs); err != nil {
t.Fatalf("FFMA references: %v", err)
}
if len(refs) != 488 {
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
}
var def Def
metas := make([]RefMeta, 0, len(refs))
for _, r := range refs {
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
// water. Pinning a reference to a DXCC entity would reject those contacts.
if r.DXCC != 0 {
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
}
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
}
for _, d := range Defaults() {
if d.Code == "FFMA" {
def = d
}
}
if def.Total != 488 || def.Field != "grid4" {
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
}
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
qsos := []qso.QSO{
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
var got []string
for _, rf := range r.Refs {
if rf.Worked {
got = append(got, rf.Ref)
}
}
sort.Strings(got)
want := []string{"EM00", "FN25", "FN31", "FN32"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("FFMA worked = %v, want %v", got, want)
}
if r.Worked != len(want) || r.Total != 488 {
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
@@ -508,3 +575,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
// Catalog() skips unparseable files rather than returning nil, so the others
// still load. (Verified structurally: the loader `continue`s on error.)
}
// Anything OpsLog SHIPS is built-in, whatever the file says.
//
// You create an award, export it (its JSON records builtin:false — it wasn't
// built-in when you wrote it), then drop that same file into the catalog to ship
// it. If we trusted the flag, the award would go out to everyone marked "not
// built-in" and would then silently miss every future catalog correction. Making
// the author remember to flip a flag first is a step nobody can guess — so the
// catalog derives it instead.
func TestCatalogForcesBuiltin(t *testing.T) {
for _, e := range Catalog() {
if !e.Def.Builtin {
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
}
}
// The realistic case: a user-authored award whose JSON says builtin:false.
if len(Catalog()) == 0 {
t.Fatal("empty catalog")
}
}
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "RAC",
"name": "RAC Canadian Provinces",
"description": "RAC Canadian Provinces",
"valid": true,
"protected": true,
"url": "https://www.rac.ca/canadaward/",
"valid_from": "1977-01-07",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
1
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AB",
"name": "Alberta",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "BC",
"name": "British Columbia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Manitoba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NB",
"name": "New Brunswick",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NL",
"name": "Newfoundland and Labrador",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NS",
"name": "Nova Scotia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NT",
"name": "Northwest Territories",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nunavut",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "ON",
"name": "Ontario",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Prince Edward Island",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "QC",
"name": "Quebec",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "SK",
"name": "Saskatchewan",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "YT",
"name": "Yukon",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "VUCC",
"name": "VHF/UHF Century Club",
"description": "VHF/UHF Century Club",
"valid": true,
"protected": true,
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
"valid_from": "1970-01-01",
"valid_to": "9999-01-30",
"type": "QSOFIELDS",
"field": "grid4",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"valid_bands": [
"6m",
"4m",
"2m",
"70cm",
"23cm",
"13cm",
"1.25m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": null
}
]
}