feat: awards folder in the data folder, anybody can create its own awards.

This commit is contained in:
2026-07-13 18:03:06 +02:00
parent c170d6091e
commit 656e238a59
8 changed files with 950 additions and 31 deletions
+150 -8
View File
@@ -737,6 +737,7 @@ func (a *App) startup(ctx context.Context) {
a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
a.seedBuiltinReferences() // first-run: populate built-in award reference lists
a.mirrorAwards() // keep <data>/awards/*.json in step with the database
a.operating = operating.NewRepo(conn)
a.udpRepo = udp.NewRepo(conn)
a.udp = udp.NewManager(a.udpRepo)
@@ -2225,7 +2226,97 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b))
if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil {
return err
}
go a.mirrorAwardsToFolder(defs)
return nil
}
// mirrorAwards refreshes the awards folder from the database.
//
// It is called from EVERY path that can change an award — the definitions AND the
// references. Mirroring only on "save definitions" was the obvious thing to do and
// the wrong one: add a city regex to a WAPC reference and the definition never
// changes, so the file would silently go stale and you'd share yesterday's award
// believing it current. A mirror that is only sometimes a mirror is worse than no
// mirror, because you trust it.
func (a *App) mirrorAwards() {
if a.settings == nil {
return
}
go a.mirrorAwardsToFolder(a.awardDefs())
}
// mirrorAwardsToFolder writes one JSON per award into <data>/awards/, and removes
// the files of awards that no longer exist.
//
// EVERY award, built-in included. Skipping the built-ins seemed tidy — why mirror
// ten awards the user never wrote? — but it broke the one case that matters: fix
// DDFM's regex and there is no JSON to hand round or drop into the catalog. The
// rule has no exceptions now, so it needs no explaining: the folder holds your
// awards, as files, always current.
//
// The folder is a MIRROR — an output, never an input. Making it an input too (drop
// a file in and it installs) reads well until you delete an award in the UI, the
// file survives, and the award walks back in on the next start. Receiving an award
// is what Import is for, where you get a say.
func (a *App) mirrorAwardsToFolder(defs []award.Def) {
dir := a.AwardsFolder()
if dir == "" || a.awardRefs == nil {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
applog.Printf("awards: cannot create %s: %v", dir, err)
return
}
keep := map[string]bool{}
for _, d := range defs {
code := strings.ToUpper(strings.TrimSpace(d.Code))
if code == "" {
continue
}
refs, err := a.awardRefs.List(a.ctx, d.Code)
if err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
continue
}
bundle := AwardBundle{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Awards: []AwardBundleEntry{{Def: d, References: refs}},
}
b, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
continue
}
name := strings.ToLower(code) + ".json"
keep[name] = true
// Temp file + rename: a crash mid-write must not leave a truncated award.
tmp := filepath.Join(dir, name+".tmp")
if err := os.WriteFile(tmp, b, 0o644); err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
continue
}
if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil {
applog.Printf("awards: mirror %s: %v", code, err)
}
}
// Delete the file of an award that no longer exists — otherwise a deleted award
// leaves a ghost file that looks current and would be shared by mistake.
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, f := range entries {
n := strings.ToLower(f.Name())
if f.IsDir() || !strings.HasSuffix(n, ".json") || keep[n] {
continue
}
if err := os.Remove(filepath.Join(dir, f.Name())); err == nil {
applog.Printf("awards: removed %s (award no longer exists)", f.Name())
}
}
}
// ResetAwardDefs restores the built-in defaults.
@@ -3280,7 +3371,11 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
if a.awardRefs == nil {
return fmt.Errorf("db not initialized")
}
return a.awardRefs.Upsert(a.ctx, code, ref)
if err := a.awardRefs.Upsert(a.ctx, code, ref); err != nil {
return err
}
a.mirrorAwards()
return nil
}
// DeleteAwardReference removes one reference from an award.
@@ -3288,7 +3383,11 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
if a.awardRefs == nil {
return fmt.Errorf("db not initialized")
}
return a.awardRefs.Delete(a.ctx, code, refCode)
if err := a.awardRefs.Delete(a.ctx, code, refCode); err != nil {
return err
}
a.mirrorAwards()
return nil
}
// ReplaceAwardReferences atomically replaces an award's whole reference list
@@ -3304,6 +3403,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.mirrorAwards()
return n, nil
}
@@ -3369,10 +3469,52 @@ 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.
// awardsDirName is the drop folder for award JSON files, inside OpsLog's data
// directory.
//
// The embedded catalog is compiled into the binary, so adding an award to it means
// a rebuild and a release — fine for what OpsLog ships officially, useless for
// "someone made an award and wants to hand it round". This folder is the answer:
// drop a JSON in, restart, the award is installed. No recompile, nobody to ask.
//
// It lives in the DATA directory, never in a cloud-synced folder — replicating
// files byte-by-byte is the mess we're avoiding everywhere else.
const awardsDirName = "awards"
// AwardsFolder is where award JSON files can be dropped to install them.
func (a *App) AwardsFolder() string {
if a.dataDir == "" {
return ""
}
return filepath.Join(a.dataDir, awardsDirName)
}
// OpenAwardsFolder reveals the drop folder in the file manager, creating it if
// needed — telling someone a path they then have to build by hand is a poor
// substitute for opening it.
func (a *App) OpenAwardsFolder() error {
dir := a.AwardsFolder()
if dir == "" {
return fmt.Errorf("data dir not initialized")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return exec.Command("explorer", dir).Start()
}
// catalogRefs returns the reference list a SHIPPED award carries, if any. Awards
// whose list is generated from code (DXCC entities, French departments) or fetched
// online (POTA/SOTA/WWFF) carry none.
func (a *App) catalogRefs(code string) (json.RawMessage, bool) {
return award.CatalogRefs(code)
}
// GetCatalogCodes lists the awards SHIPPED with OpsLog. Anything in the database
// that is NOT in this list is YOURS — you created or imported it, and it is not
// part of what OpsLog delivers. The editor flags those so it's obvious at a glance
// which awards are your own work (and therefore which files in the awards folder
// are yours to share).
func (a *App) GetCatalogCodes() []string {
cat := award.Catalog()
out := make([]string, 0, len(cat))
@@ -3714,7 +3856,7 @@ func (a *App) seedBuiltinReferences() {
// 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 {
if raw, ok := a.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 {