feat: awards folder in the data folder, anybody can create its own awards.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user