feat: Reworked the awards logic so it is easy to add new ones.

This commit is contained in:
2026-07-13 17:38:18 +02:00
parent ae60d58893
commit c170d6091e
18 changed files with 994 additions and 71 deletions
+253 -36
View File
@@ -3366,9 +3366,46 @@ type AwardImportResult struct {
// reference list to a JSON bundle. Returns the path written, or "" if the user
// cancelled.
func (a *App) ExportAwards() (string, error) {
return a.exportAwardBundle(nil, "OpsLog_awards_"+time.Now().UTC().Format("20060102_150405")+".json", "Export awards")
}
// GetCatalogCodes lists the awards SHIPPED with OpsLog (the embedded JSON
// catalog). Anything in the database that is NOT in this list exists only on this
// machine: it is yours, nobody else has it, and it is lost if you reinstall
// without exporting it. The editor marks those so they're impossible to miss.
func (a *App) GetCatalogCodes() []string {
cat := award.Catalog()
out := make([]string, 0, len(cat))
for _, e := range cat {
out = append(out, strings.ToUpper(strings.TrimSpace(e.Def.Code)))
}
return out
}
// ExportAward writes ONE award to its own JSON file — the unit you actually share.
// The whole-catalogue bundle is a backup; a single award is what you send someone.
//
// It reads the DEFINITION AND ITS REFERENCES FROM THE DATABASE, not from the
// embedded catalog: your WAPC is only worth sharing because of the province list
// and the city regexes you added, and those live in the database. Exporting the
// catalog's version would hand over an empty shell.
func (a *App) ExportAward(code string) (string, error) {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return "", fmt.Errorf("no award selected")
}
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil {
return "", fmt.Errorf("db not initialized")
}
want := map[string]bool{}
for _, c := range codes {
want[strings.ToUpper(strings.TrimSpace(c))] = true
}
defs := a.awardDefs()
bundle := AwardBundle{
Version: 1,
@@ -3376,19 +3413,25 @@ func (a *App) ExportAwards() (string, error) {
Awards: make([]AwardBundleEntry, 0, len(defs)),
}
for _, d := range defs {
if len(want) > 0 && !want[strings.ToUpper(d.Code)] {
continue
}
refs, err := a.awardRefs.List(a.ctx, d.Code)
if err != nil {
return "", fmt.Errorf("list references for %s: %w", d.Code, err)
}
bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs})
}
if len(bundle.Awards) == 0 {
return "", fmt.Errorf("nothing to export")
}
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Export awards",
DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json",
Title: title,
DefaultFilename: defaultName,
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
@@ -3408,51 +3451,177 @@ func (a *App) ExportAwards() (string, error) {
// replaces that award's list. Returns counts; the user cancelling yields a
// zero result and no error.
func (a *App) ImportAwards() (AwardImportResult, error) {
var res AwardImportResult
// Kept for the old call sites: inspect, then apply with the default decision.
// Anything that already exists is SKIPPED, never silently replaced.
p, err := a.InspectAwardImport()
if err != nil || p.Path == "" {
return AwardImportResult{}, err
}
dec := map[string]string{}
for _, e := range p.Awards {
if e.Exists {
dec[e.Code] = "skip"
} else {
dec[e.Code] = "replace"
}
}
return a.ApplyAwardImport(p.Path, dec)
}
// AwardImportPreviewEntry describes one award found in a file to import.
type AwardImportPreviewEntry struct {
Code string `json:"code"`
Name string `json:"name"`
References int `json:"references"`
Exists bool `json:"exists"` // an award with this code is already installed
MineName string `json:"mine_name"` // the name of the one you already have
MineRefs int `json:"mine_refs"` // how many references yours carries
Protected bool `json:"protected"` // yours is a protected built-in
}
// AwardImportPreview is what the file holds, and what would collide.
type AwardImportPreview struct {
Path string `json:"path"`
Awards []AwardImportPreviewEntry `json:"awards"`
}
// InspectAwardImport opens a bundle and reports what it contains WITHOUT touching
// anything — so the UI can ask before overwriting.
//
// The import used to merge by code with "imported wins", silently. Import a WAPC
// someone shared and YOUR WAPC — its province list, its city regexes, its band
// scope — was destroyed without a word. Sharing awards is precisely the feature we
// want people to use, so it must not be a data-loss trap: we look first, then ask.
func (a *App) InspectAwardImport() (AwardImportPreview, error) {
var p AwardImportPreview
if a.awardRefs == nil || a.settings == nil {
return res, fmt.Errorf("db not initialized")
return p, fmt.Errorf("db not initialized")
}
path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Import awards",
Title: "Import award(s)",
Filters: []wruntime.FileFilter{
{DisplayName: "Award bundle (*.json)", Pattern: "*.json"},
{DisplayName: "All files (*.*)", Pattern: "*.*"},
},
})
if err != nil || path == "" {
return res, err
return p, err
}
data, err := os.ReadFile(path)
bundle, err := readAwardBundle(path)
if err != nil {
return res, fmt.Errorf("read %s: %w", path, err)
return p, err
}
var bundle AwardBundle
if err := json.Unmarshal(data, &bundle); err != nil {
return res, fmt.Errorf("parse award bundle: %w", err)
}
if len(bundle.Awards) == 0 {
return res, fmt.Errorf("no awards in file")
}
// Merge definitions: upsert by code (imported wins), keep the rest.
defs := a.awardDefs()
byCode := map[string]int{}
for i, d := range defs {
byCode[strings.ToUpper(d.Code)] = i
mine := map[string]award.Def{}
for _, d := range a.awardDefs() {
mine[strings.ToUpper(d.Code)] = d
}
p.Path = path
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
if i, ok := byCode[code]; ok {
defs[i] = e.Def
} else {
byCode[code] = len(defs)
defs = append(defs, e.Def)
entry := AwardImportPreviewEntry{
Code: code, Name: e.Def.Name, References: len(e.References),
}
res.Awards++
if d, ok := mine[code]; ok {
entry.Exists = true
entry.MineName = d.Name
entry.Protected = d.Protected
if refs, err := a.awardRefs.List(a.ctx, code); err == nil {
entry.MineRefs = len(refs)
}
}
p.Awards = append(p.Awards, entry)
}
if len(p.Awards) == 0 {
return p, fmt.Errorf("no awards in file")
}
return p, nil
}
// ApplyAwardImport imports a bundle, applying the operator's decision per code:
//
// "replace" — take theirs, overwriting mine
// "skip" — keep mine, ignore the import
// "copy" — install theirs under a free code (WAPC-2), so both exist and can be
// compared before one is deleted
//
// "copy" is the one that earns its keep: it lets you LOOK before choosing, without
// losing anything either way.
func (a *App) ApplyAwardImport(path string, decisions map[string]string) (AwardImportResult, error) {
var res AwardImportResult
if a.awardRefs == nil || a.settings == nil {
return res, fmt.Errorf("db not initialized")
}
bundle, err := readAwardBundle(path)
if err != nil {
return res, err
}
defs := a.awardDefs()
idx := map[string]int{}
for i, d := range defs {
idx[strings.ToUpper(d.Code)] = i
}
// refsToWrite is applied only after the definitions save cleanly — a half-done
// import that swapped the reference list but not the definition would leave the
// award quietly broken.
type pending struct {
code string
refs []awardref.Ref
}
var refsToWrite []pending
for _, e := range bundle.Awards {
code := strings.ToUpper(strings.TrimSpace(e.Def.Code))
if code == "" {
continue
}
decision := strings.ToLower(strings.TrimSpace(decisions[code]))
_, exists := idx[code]
if !exists {
decision = "replace" // nothing to collide with
}
switch decision {
case "skip", "":
continue
case "copy":
newCode := freeAwardCode(code, idx)
d := e.Def
d.Code = newCode
// An imported copy is NOT a protected built-in: the operator must be able
// to delete it once they've decided which one they keep.
d.Builtin, d.Protected = false, false
idx[newCode] = len(defs)
defs = append(defs, d)
res.Awards++
if len(e.References) > 0 {
refsToWrite = append(refsToWrite, pending{newCode, e.References})
}
default: // "replace"
d := e.Def
d.Code = code
if i, ok := idx[code]; ok {
defs[i] = d
} else {
idx[code] = len(defs)
defs = append(defs, d)
}
res.Awards++
// An entry with NO references leaves the existing list alone — otherwise
// importing a def-only export would wipe a seeded built-in list.
if len(e.References) > 0 {
refsToWrite = append(refsToWrite, pending{code, e.References})
}
}
}
if res.Awards == 0 {
return res, nil // everything skipped
}
if migrated, changed := award.Migrate(defs); changed {
defs = migrated
}
@@ -3460,22 +3629,54 @@ func (a *App) ImportAwards() (AwardImportResult, error) {
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return res, fmt.Errorf("save award defs: %w", err)
}
// Replace reference lists for entries that carry them (skip empty so we
// don't wipe built-in-seeded lists for a def exported without refs).
for _, e := range bundle.Awards {
if len(e.References) == 0 {
continue
}
n, err := a.ReplaceAwardReferences(e.Def.Code, e.References)
for _, p := range refsToWrite {
n, err := a.ReplaceAwardReferences(p.code, p.refs)
if err != nil {
return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err)
return res, fmt.Errorf("import references for %s: %w", p.code, err)
}
res.References += n
}
a.invalidateAwardStats()
return res, nil
}
// readAwardBundle parses a bundle file. Size-capped: an award bundle is a few
// hundred kB at most, and an unbounded read of a file someone sent you is not a
// risk worth taking.
func readAwardBundle(path string) (AwardBundle, error) {
var bundle AwardBundle
fi, err := os.Stat(path)
if err != nil {
return bundle, fmt.Errorf("read %s: %w", path, err)
}
const maxBundle = 32 << 20 // 32 MB
if fi.Size() > maxBundle {
return bundle, fmt.Errorf("%s is too large for an award bundle (%d bytes)", path, fi.Size())
}
data, err := os.ReadFile(path)
if err != nil {
return bundle, fmt.Errorf("read %s: %w", path, err)
}
if err := json.Unmarshal(data, &bundle); err != nil {
return bundle, fmt.Errorf("parse award bundle: %w", err)
}
if len(bundle.Awards) == 0 {
return bundle, fmt.Errorf("no awards in file")
}
return bundle, nil
}
// freeAwardCode returns the first unused "CODE-n" (WAPC-2, WAPC-3, …).
func freeAwardCode(code string, taken map[string]int) string {
for n := 2; n < 100; n++ {
c := fmt.Sprintf("%s-%d", code, n)
if _, clash := taken[c]; !clash {
return c
}
}
return code + "-COPY"
}
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
@@ -3508,6 +3709,22 @@ func (a *App) seedBuiltinReferences() {
if firstRun && counts[code] > 0 {
continue // don't overwrite an existing list on a fresh install
}
// 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
// city regexes) reaches every user without a line of Go. Awards whose list
// is generated from code (DXCC entities, French departments) or fetched
// online (POTA/SOTA/WWFF) carry none, and fall back below.
if raw, ok := award.CatalogRefs(code); ok {
var refs []awardref.Ref
if err := json.Unmarshal(raw, &refs); err == nil && len(refs) > 0 {
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
applog.Printf("award-refs: seeded %s from the catalog — %d references", code, n)
continue
}
} else if err != nil {
applog.Printf("award-refs: %s catalog references are malformed: %v", code, err)
}
}
if refs, ok := awardref.BuiltinRefs(code); ok {
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
applog.Printf("award-refs: seeded %s — %d references", code, n)