feat: versioning in awards definition
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user