feat: Reworked the awards logic so it is easy to add new ones.
This commit is contained in:
+111
-19
@@ -13,6 +13,8 @@
|
||||
package award
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -113,26 +115,116 @@ type OrRule struct {
|
||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||
}
|
||||
|
||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
||||
func Defaults() []Def {
|
||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
||||
// accepts it (WAC).
|
||||
lq := []string{"lotw", "qsl"}
|
||||
lo := []string{"lotw"}
|
||||
return []Def{
|
||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
||||
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||
//
|
||||
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||
// recompile and a release for every new one, which is absurd for something that
|
||||
// changes far more often than the engine that reads it. They now live one JSON per
|
||||
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||
//
|
||||
// This is the SEED only. Once a user has awards in their database, that database
|
||||
// is the source of truth — the catalog never overwrites their edits behind their
|
||||
// back.
|
||||
//
|
||||
//go:embed catalog/*.json
|
||||
var catalogFS embed.FS
|
||||
|
||||
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||
//
|
||||
// The references are the point. An award's definition is a few lines; what makes
|
||||
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||
// A catalog that shipped definitions only would hand every user an empty shell —
|
||||
// which is exactly the trap this design is meant to avoid.
|
||||
//
|
||||
// References stay as raw JSON so this package (the matching ENGINE) never has to
|
||||
// import the reference-store package. The caller decodes them.
|
||||
type CatalogEntry struct {
|
||||
Def Def `json:"def"`
|
||||
References json.RawMessage `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
|
||||
//
|
||||
// {"def": {...}, "references": [...]} — a catalog entry
|
||||
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
|
||||
//
|
||||
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
|
||||
// can be dropped straight into catalog/ and shipped to everyone, with no
|
||||
// conversion step. That is the whole loop — create an award, export it, drop it in,
|
||||
// everybody has it.
|
||||
type catalogFile struct {
|
||||
Def Def `json:"def"`
|
||||
References json.RawMessage `json:"references,omitempty"`
|
||||
Awards []CatalogEntry `json:"awards,omitempty"`
|
||||
// A bare Def (the original catalog shape) is still read via the fields above
|
||||
// being empty and Code being set at the top level.
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Catalog returns every built-in award: definition + references, sorted by code.
|
||||
//
|
||||
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
|
||||
// how a user's award list quietly reorders itself on a rebuild.
|
||||
func Catalog() []CatalogEntry {
|
||||
entries, err := catalogFS.ReadDir("catalog")
|
||||
if err != nil {
|
||||
return nil // embedded: can only fail if the build is broken
|
||||
}
|
||||
out := make([]CatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
b, err := catalogFS.ReadFile("catalog/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var f catalogFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
// A malformed file must not take the other awards down with it.
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
|
||||
for _, a := range f.Awards {
|
||||
if strings.TrimSpace(a.Def.Code) != "" {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
|
||||
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
|
||||
case strings.TrimSpace(f.Code) != "": // a bare Def
|
||||
var d Def
|
||||
if err := json.Unmarshal(b, &d); err == nil {
|
||||
out = append(out, CatalogEntry{Def: d})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||
return out
|
||||
}
|
||||
|
||||
// Defaults are the built-in award definitions seeded on first run.
|
||||
func Defaults() []Def {
|
||||
cat := Catalog()
|
||||
out := make([]Def, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, e.Def)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
|
||||
// any. Awards whose list is seeded from code (DXCC entities, French departments)
|
||||
// or fetched online (POTA/SOTA/WWFF) carry none.
|
||||
func CatalogRefs(code string) (json.RawMessage, bool) {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
for _, e := range Catalog() {
|
||||
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
|
||||
return e.References, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Migrate upgrades award definitions saved before the richer model existed.
|
||||
|
||||
Reference in New Issue
Block a user