feat: Reworked the awards logic so it is easy to add new ones.
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user