diff --git a/app.go b/app.go index aeaa40c..ec9ec80 100644 --- a/app.go +++ b/app.go @@ -126,6 +126,7 @@ const ( keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp keyAwardRefsSeeded = "awards.refs.seeded" // built-in reference-list seed version keyAwardDefsFixed = "awards.defs.fixed" // built-in award def correction version + keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions @@ -2155,9 +2156,11 @@ func (a *App) awardDefs() []award.Def { if strings.TrimSpace(s) != "" { var defs []award.Def if json.Unmarshal([]byte(s), &defs) == nil && len(defs) > 0 { - // Upgrade legacy defs (pre-rich-model) in memory on every load. + // Upgrade legacy defs (pre-rich-model) in memory on every load, and + // reconcile with the catalog so a newly shipped (or newly fixed) award + // is visible even before migrateAwardDefs has written it back. migrated, _ := award.Migrate(defs) - migrated, _ = mergeCatalog(migrated) + migrated, _, _ = mergeCatalog(migrated) return migrated } } @@ -2165,25 +2168,88 @@ 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{}{} +// mergeCatalog reconciles the stored award definitions with the catalog OpsLog +// ships. Two distinct jobs: +// +// - ADD an award the catalog has and storage doesn't (FFMA in a new release). +// Without this, awardDefs() would keep returning the stored copy for ever and +// a newly shipped award would simply never appear. +// - UPDATE a shipped award whose catalog Version is higher than the stored one +// — that is how a FIX to an award we already distributed (a corrected OR +// chain, a fixed reference list) actually reaches its users. Distributing an +// award you can never afterwards correct is only half a mechanism. +// +// An award the operator has edited (UserEdited) is NEVER updated: their work +// outranks ours. It keeps its stored version, so if they later reset it the +// update applies then. +// +// Returns the reconciled defs and the codes whose DEFINITION was replaced — the +// caller re-seeds those references from the catalog. +func mergeCatalog(defs []award.Def) ([]award.Def, []string, bool) { + idx := make(map[string]int, len(defs)) + for i, d := range defs { + idx[strings.ToUpper(strings.TrimSpace(d.Code))] = i } - added := false - for _, d := range award.Defaults() { - if _, ok := have[strings.ToUpper(strings.TrimSpace(d.Code))]; ok { + var updated []string + changed := false + for _, cd := range award.Defaults() { + code := strings.ToUpper(strings.TrimSpace(cd.Code)) + i, ok := idx[code] + if !ok { + defs = append(defs, cd) + changed = true continue } - defs = append(defs, d) - added = true + stored := defs[i] + if stored.UserEdited || cd.Version <= stored.Version { + continue + } + // Keep the operator's own switch: an award they deliberately disabled must + // not come back enabled just because we shipped a fix. That is a preference, + // not a definition. + cd.Valid = stored.Valid + defs[i] = cd + updated = append(updated, code) + changed = true } - return defs, added + return defs, updated, changed +} + +func defByCode(defs []award.Def, code string) award.Def { + for _, d := range defs { + if strings.EqualFold(strings.TrimSpace(d.Code), code) { + return d + } + } + return award.Def{} +} + +// reseedRefsFromCatalog replaces an award's reference list with the one the +// catalog ships. Only called for an award whose definition we just updated and +// that the operator has NOT edited, so nothing of theirs is overwritten. Awards +// whose list is generated (DXCC, French departments) or fetched online +// (POTA/SOTA/WWFF) ship none — those are left alone. +func (a *App) reseedRefsFromCatalog(code string) { + if a.awardRefs == nil { + return + } + raw, ok := award.CatalogRefs(code) + if !ok { + return + } + var refs []awardref.Ref + if err := json.Unmarshal(raw, &refs); err != nil || len(refs) == 0 { + if err != nil { + applog.Printf("awards: %s catalog references are malformed: %v", code, err) + } + return + } + n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs) + if err != nil { + applog.Printf("awards: re-seeding %s from the catalog failed: %v", code, err) + return + } + applog.Printf("awards: %s references re-seeded from the catalog — %d", code, n) } // GetAwardDefs returns the (editable) award definitions. @@ -2211,11 +2277,47 @@ 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 { + + // Back-fill the user-edited flag, once. The flag is what stops a catalog update + // from overwriting the operator's own work — but it only starts being recorded + // from the release that introduced it, so an award customised BEFORE that would + // look pristine and get silently clobbered by the first update we ship. + // + // No award version has ever been bumped yet, so today any shipped award whose + // definition differs from the catalog can only have been changed by its owner. + // That makes the back-fill exact, and it is only ever right to run it once — + // after the first version bump, "differs from the catalog" would just mean + // "older revision". + if v, _ := a.settings.GetGlobal(a.ctx, keyAwardEditsSeeded); v != "1" { + cat := map[string]award.Def{} + for _, d := range award.Defaults() { + cat[strings.ToUpper(strings.TrimSpace(d.Code))] = d + } + for i := range migrated { + c, ok := cat[strings.ToUpper(strings.TrimSpace(migrated[i].Code))] + if ok && !migrated[i].UserEdited && !c.SameContent(migrated[i]) { + migrated[i].UserEdited = true + changed = true + applog.Printf("awards: %s differs from the catalog — marked as yours, catalog updates will leave it alone", migrated[i].Code) + } + } + a.setSettingGlobal(keyAwardEditsSeeded, "1") + } + + // Reconcile with the catalog: awards ADDED since this operator last saved, and + // shipped awards whose definition we have since FIXED (a higher Version). Both + // must be written back, or the editor would keep showing the old set. + merged, updated, mc := mergeCatalog(migrated) + if mc { migrated, changed = merged, true } + // A replaced definition gets the catalog's reference list back too — a fix is + // usually BOTH (a new OR chain and the references its regexes rely on), and a + // half-applied update is worse than none. + for _, code := range updated { + applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version) + a.reseedRefsFromCatalog(code) + } // 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 @@ -2244,11 +2346,35 @@ func (a *App) migrateAwardDefs() { } } +// markUserEdited flags every award whose definition differs from what was stored +// before this save. The flag is what makes a catalog update safe: it tells us +// which awards carry the operator's own work, so we never overwrite one with a +// newer shipped version. It is sticky — once edited, an award stays theirs until +// they reset it — and it is set here, on the save path, rather than trusted from +// the frontend, which has no reason to reason about it. +func markUserEdited(next, prev []award.Def) { + before := make(map[string]award.Def, len(prev)) + for _, d := range prev { + before[strings.ToUpper(strings.TrimSpace(d.Code))] = d + } + for i := range next { + old, ok := before[strings.ToUpper(strings.TrimSpace(next[i].Code))] + if !ok { + continue // brand-new award: not a catalog award, nothing to protect it from + } + if old.UserEdited || !old.SameContent(next[i]) { + next[i].UserEdited = true + } + next[i].Version = old.Version // the version says which SHIPPED revision this came from; a save never bumps it + } +} + // SaveAwardDefs persists edited award definitions. func (a *App) SaveAwardDefs(defs []award.Def) error { if a.settings == nil { return fmt.Errorf("db not initialized") } + markUserEdited(defs, a.awardDefs()) b, err := json.Marshal(defs) if err != nil { return err @@ -2348,10 +2474,25 @@ func (a *App) mirrorAwardsToFolder(defs []award.Def) { // ResetAwardDefs restores the built-in defaults. func (a *App) ResetAwardDefs() ([]award.Def, error) { + if a.settings == nil { + return nil, fmt.Errorf("db not initialized") + } d := award.Defaults() - if err := a.SaveAwardDefs(d); err != nil { + // Written directly, NOT through SaveAwardDefs: that path marks whatever it + // saves as user-edited, and a reset means the exact opposite — the operator is + // handing the awards back to the catalog, flags cleared, so future updates + // apply again. Their reference lists go back to the shipped ones too. + b, err := json.Marshal(d) + if err != nil { return nil, err } + if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil { + return nil, err + } + for _, def := range d { + a.reseedRefsFromCatalog(def.Code) + } + go a.mirrorAwardsToFolder(d) return d, nil } @@ -3401,6 +3542,7 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error { if err := a.awardRefs.Upsert(a.ctx, code, ref); err != nil { return err } + a.markAwardEdited(code) a.mirrorAwards() return nil } @@ -3413,10 +3555,36 @@ func (a *App) DeleteAwardReference(code, refCode string) error { if err := a.awardRefs.Delete(a.ctx, code, refCode); err != nil { return err } + a.markAwardEdited(code) a.mirrorAwards() return nil } +// markAwardEdited records that the operator has hand-modified an award's +// REFERENCE list, so a catalog update never overwrites it. Only the manual paths +// call this: the online refresh of POTA/SOTA/WWFF replaces the list too, but that +// is a data sync, not a customisation, and treating it as one would freeze those +// awards out of every future fix. +func (a *App) markAwardEdited(code string) { + if a.settings == nil { + return + } + defs := a.awardDefs() + touched := false + for i := range defs { + if strings.EqualFold(strings.TrimSpace(defs[i].Code), strings.TrimSpace(code)) && !defs[i].UserEdited { + defs[i].UserEdited = true + touched = true + } + } + if !touched { + return + } + if b, err := json.Marshal(defs); err == nil { + a.setSettingGlobal(keyAwardDefs, string(b)) + } +} + // ReplaceAwardReferences atomically replaces an award's whole reference list // (used by paste / CSV import and presets). Returns the new count. func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, error) { @@ -3430,6 +3598,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err if a.settings != nil { a.setSetting(keyAwardRefsUpdated+strings.ToUpper(code), time.Now().Format("2006-01-02 15:04")) } + a.markAwardEdited(code) a.mirrorAwards() return n, nil } diff --git a/awards_catalog_test.go b/awards_catalog_test.go new file mode 100644 index 0000000..c5a5a6d --- /dev/null +++ b/awards_catalog_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "testing" + + "hamlog/internal/award" +) + +// The catalog is the channel through which a shipped award is both DELIVERED and +// CORRECTED. These tests pin the three rules that make that safe: +// - a new catalog award reaches an operator who already has awards stored; +// - a fixed one (higher Version) replaces the stored copy; +// - unless the operator has edited it, in which case their work wins. + +func catalogDef(t *testing.T, code string) award.Def { + t.Helper() + for _, d := range award.Defaults() { + if d.Code == code { + return d + } + } + t.Fatalf("%s is not in the catalog", code) + return award.Def{} +} + +func findDef(defs []award.Def, code string) (award.Def, bool) { + for _, d := range defs { + if d.Code == code { + return d, true + } + } + return award.Def{}, false +} + +func TestMergeCatalogAddsMissingAward(t *testing.T) { + stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}} + got, updated, changed := mergeCatalog(stored) + if !changed { + t.Fatal("merge reported no change, but every catalog award was missing") + } + if len(updated) != 0 { + t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated) + } + if _, ok := findDef(got, "FFMA"); !ok { + t.Error("FFMA was not added — a newly shipped award never reaches an existing install") + } + if _, ok := findDef(got, "MYOWN"); !ok { + t.Error("the operator's own award was dropped by the merge") + } +} + +func TestMergeCatalogUpdatesOlderVersion(t *testing.T) { + // The stored copy is an older revision of a shipped award, with a broken rule. + old := catalogDef(t, "FFMA") + old.Version = catalogDef(t, "FFMA").Version - 1 + old.Field = "wrong" + old.Valid = false // the operator disabled it — a preference, not a definition + + got, updated, changed := mergeCatalog([]award.Def{old}) + if !changed || len(updated) == 0 { + t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED") + } + d, _ := findDef(got, "FFMA") + if d.Field != "grid4" { + t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4") + } + if d.Valid { + t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours") + } +} + +func TestMergeCatalogSkipsUserEdited(t *testing.T) { + old := catalogDef(t, "FFMA") + old.Version = catalogDef(t, "FFMA").Version - 1 + old.Field = "mine" + old.UserEdited = true + + got, updated, _ := mergeCatalog([]award.Def{old}) + if len(updated) != 0 { + t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated) + } + if d, _ := findDef(got, "FFMA"); d.Field != "mine" { + t.Errorf("field = %q, want the operator's %q", d.Field, "mine") + } +} + +func TestMarkUserEditedOnlyOnRealChange(t *testing.T) { + prev := []award.Def{ + {Code: "A", Name: "A", Field: "state", Valid: true, Version: 2}, + {Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2}, + } + next := []award.Def{ + {Code: "A", Name: "A", Field: "state", Valid: true}, // untouched + {Code: "B", Name: "B", Field: "county", Valid: true}, // changed + {Code: "C", Name: "C", Field: "note", Valid: true}, // brand new + } + markUserEdited(next, prev) + + if next[0].UserEdited { + t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates") + } + if !next[1].UserEdited { + t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work") + } + if next[2].UserEdited { + t.Error("C is a brand-new award; there is no shipped version to protect it from") + } + // A save must not pretend to be a new shipped revision. + if next[0].Version != 2 || next[1].Version != 2 { + t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version) + } +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2095c9e..4918c64 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -274,6 +274,8 @@ export namespace award { export_credit_granted?: boolean; total: number; builtin: boolean; + version?: number; + user_edited?: boolean; static createFrom(source: any = {}) { return new Def(source); @@ -314,6 +316,8 @@ export namespace award { this.export_credit_granted = source["export_credit_granted"]; this.total = source["total"]; this.builtin = source["builtin"]; + this.version = source["version"]; + this.user_edited = source["user_edited"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/award/award.go b/internal/award/award.go index 1d7719c..fe665cc 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -99,6 +99,35 @@ type Def struct { Total int `json:"total"` // known denominator (0 = unknown / derive from list) Builtin bool `json:"builtin"` // shipped default (informational) + + // --- Catalog updates --- + // Version is the revision of a SHIPPED award. Bump it in the catalog JSON when + // you fix a definition (a better OR chain, a corrected reference list) and want + // that fix to reach operators who already run the award: on startup, a catalog + // award whose Version is higher than the stored one REPLACES it, definition and + // references. Awards created by the operator have no version and are never touched. + Version int `json:"version,omitempty"` + // UserEdited marks an award the operator has changed. A catalog update then + // SKIPS it: their work outranks ours. Set the moment the award (or its reference + // list) is saved to something other than what the catalog ships. + UserEdited bool `json:"user_edited,omitempty"` +} + +// SameContent reports whether two definitions describe the same award — ignoring +// the bookkeeping fields (version, the user-edited flag, the derived builtin bit), +// which say where a definition came from, not what it does. Used to decide whether +// a save actually changed anything. +func (d Def) SameContent(o Def) bool { + a, b := d, o + a.Version, b.Version = 0, 0 + a.UserEdited, b.UserEdited = false, false + a.Builtin, b.Builtin = false, false + ja, err1 := json.Marshal(a) + jb, err2 := json.Marshal(b) + if err1 != nil || err2 != nil { + return false + } + return string(ja) == string(jb) } // OrRule is one additional search OR'd with the award's primary matching rule.