feat: versioning in awards definition

This commit is contained in:
2026-07-14 15:37:42 +02:00
parent 7f95a71426
commit 1f0f75baf8
4 changed files with 335 additions and 21 deletions
+190 -21
View File
@@ -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
}
return defs, added
// 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, 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
}
+112
View File
@@ -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)
}
}
+4
View File
@@ -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 {
+29
View File
@@ -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.