diff --git a/app.go b/app.go index 158b714..3506cec 100644 --- a/app.go +++ b/app.go @@ -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 /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 /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 { diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index af27f74..6cb6865 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, Share2 } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -18,7 +18,7 @@ import { ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, - ExportAwards, ImportAwards, ExportAward, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, + ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder, } from '../../wailsjs/go/main/App'; // Above this many references the editor stops loading the whole list and @@ -238,18 +238,6 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { if (p) setErr(t('awed.exportedTo', { path: p })); } catch (e: any) { setErr(String(e?.message ?? e)); } } - // Export ONE award — the unit you actually share. It carries its definition AND - // its references AND their regexes (read from the database), so the person you - // send it to gets a working award, not an empty shell. - async function exportOne() { - if (!cur?.code) return; - setErr(''); - try { - const p = await ExportAward(cur.code); - if (p) setErr(t('awed.exportedTo', { path: p })); - } catch (e: any) { setErr(String(e?.message ?? e)); } - } - // Import: LOOK FIRST, then ask. // // This used to merge by code with "imported wins", silently — import a WAPC @@ -526,17 +514,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { - {/* Share ONE award — the unit you actually send someone. The bundle - below is a backup, which is a different job. */} - + {/* The drop folder: put an award JSON here and it installs on restart — + no rebuild, nobody to ask. Opening it beats printing a path someone + then has to retype. */} +
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index de9d804..1d89fcc 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -250,8 +250,10 @@ const en: Dict = { 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.exportOne': 'Share {code}', 'awed.onlyHere': 'local', - 'awed.onlyHereTip': 'This award exists only in YOUR database — it is not shipped with OpsLog. Nobody else has it, and a reinstall loses it unless you export it.', + 'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.', 'awed.builtin': 'Built-in', + 'awed.awardsFolder': 'Awards folder', + 'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.', 'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.', 'awed.protectedFlag': 'Protected', 'awed.protectedTip': 'Protected awards cannot be deleted from the editor.', @@ -491,8 +493,10 @@ const fr: Dict = { 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.exportOne': 'Partager {code}', 'awed.onlyHere': 'local', - 'awed.onlyHereTip': 'Ce diplôme n’existe que dans TA base — il n’est pas livré avec OpsLog. Personne d’autre ne l’a, et une réinstallation le perd si tu ne l’exportes pas.', + 'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.', 'awed.builtin': 'Intégré', + 'awed.awardsFolder': 'Dossier awards', + 'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.', 'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.', 'awed.protectedFlag': 'Protégé', 'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b301685..7603425 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -60,6 +60,8 @@ export function AwardMissingQSOs(arg1:string):Promise>; export function AwardRefsForQSOs(arg1:Array):Promise>>; +export function AwardsFolder():Promise; + export function BrowseExecutable():Promise; export function BulkUpdateField(arg1:Array,arg2:string,arg3:string):Promise; @@ -554,6 +556,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise; export function OpenADIFFile():Promise; +export function OpenAwardsFolder():Promise; + export function OpenDatabase(arg1:string):Promise; export function OpenExternalURL(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 894cb63..6e1e9b0 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -78,6 +78,10 @@ export function AwardRefsForQSOs(arg1) { return window['go']['main']['App']['AwardRefsForQSOs'](arg1); } +export function AwardsFolder() { + return window['go']['main']['App']['AwardsFolder'](); +} + export function BrowseExecutable() { return window['go']['main']['App']['BrowseExecutable'](); } @@ -1066,6 +1070,10 @@ export function OpenADIFFile() { return window['go']['main']['App']['OpenADIFFile'](); } +export function OpenAwardsFolder() { + return window['go']['main']['App']['OpenAwardsFolder'](); +} + export function OpenDatabase(arg1) { return window['go']['main']['App']['OpenDatabase'](arg1); } diff --git a/internal/award/award_test.go b/internal/award/award_test.go index f19d61d..d7467bd 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -391,9 +391,13 @@ func TestCatalogDefaults(t *testing.T) { byCode[d.Code] = d } + // The catalog is MEANT to grow — dropping a JSON in is how an award ships. So + // assert the ten originals are still there, never that the count is exactly ten: + // a test that fails the moment you add an award is a test that teaches you to + // ignore it. want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"} - if len(defs) != len(want) { - t.Fatalf("catalog has %d awards, want %d (%v)", len(defs), len(want), byCode) + if len(defs) < len(want) { + t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode) } for _, c := range want { d, ok := byCode[c] diff --git a/internal/award/catalog/waja.json b/internal/award/catalog/waja.json new file mode 100644 index 0000000..cbb36be --- /dev/null +++ b/internal/award/catalog/waja.json @@ -0,0 +1,432 @@ +{ + "version": 1, + "exported_at": "2026-07-13T15:44:38Z", + "awards": [ + { + "def": { + "code": "WAJA", + "name": "Worked All Japanese Prefectures", + "description": "Worked All Japanese Prefectures", + "valid": true, + "protected": true, + "valid_from": "1970-01-01", + "ref_display": "name", + "type": "QSOFIELDS", + "field": "qth", + "match_by": "description", + "pattern": "", + "or_rules": [ + { + "field": "qth", + "match_by": "pattern" + } + ], + "dxcc_filter": [ + 339 + ], + "valid_bands": [ + "80m", + "40m", + "20m", + "15m", + "10m", + "160m" + ], + "emission": [ + "CW", + "PHONE", + "DIGITAL" + ], + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw", + "qsl" + ], + "total": 0, + "builtin": true + }, + "references": [ + { + "code": "1", + "name": "Hokkaido", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "10", + "name": "Gunma", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "11", + "name": "Saitama", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "12", + "name": "Chiba", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "13", + "name": "Tokyo", + "dxcc": 0, + "group": "", + "subgrp": "", + "pattern": "\\bTok[iy]o\\b", + "valid": true + }, + { + "code": "14", + "name": "Kanagawa", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "15", + "name": "Niigata", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "16", + "name": "Toyama", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "17", + "name": "Ishikawa", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "18", + "name": "Fukui", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "19", + "name": "Yamanashi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "2", + "name": "Aomori", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "20", + "name": "Nagano", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "21", + "name": "Gifu", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "22", + "name": "Shizuoka", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "23", + "name": "Aichi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "24", + "name": "Mie", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "25", + "name": "Shiga", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "26", + "name": "Kyoto", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "27", + "name": "Osaka", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "28", + "name": "Hyogo", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "29", + "name": "Nara", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "3", + "name": "Iwate", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "30", + "name": "Wakayama", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "31", + "name": "Tottori", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "32", + "name": "Shimane", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "33", + "name": "Okayama", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "34", + "name": "Hiroshima", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "35", + "name": "Yamaguchi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "36", + "name": "Tokushima", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "37", + "name": "Kagawa", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "38", + "name": "Ehime", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "39", + "name": "Kochi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "4", + "name": "Miyagi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "40", + "name": "Fukuoka", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "41", + "name": "Saga", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "42", + "name": "Nagasaki", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "43", + "name": "Kumamoto", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "44", + "name": "Oita", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "45", + "name": "Miyazaki", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "46", + "name": "Kagoshima", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "47", + "name": "Okinawa", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "5", + "name": "Akita", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "6", + "name": "Yamagata", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "7", + "name": "Fukushima", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "8", + "name": "Ibaraki", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + }, + { + "code": "9", + "name": "Tochigi", + "dxcc": 0, + "group": "", + "subgrp": "", + "valid": true + } + ] + } + ] +} \ No newline at end of file diff --git a/internal/award/catalog/wapc.json b/internal/award/catalog/wapc.json new file mode 100644 index 0000000..766bc84 --- /dev/null +++ b/internal/award/catalog/wapc.json @@ -0,0 +1,335 @@ +{ + "version": 1, + "exported_at": "2026-07-13T15:43:49Z", + "awards": [ + { + "def": { + "code": "WAPC", + "name": "Worked All Provinces of China", + "description": "Worked All Provinces of China", + "valid": true, + "protected": true, + "url": "http://www.mulandxc.com/index/wapc_medal_app", + "valid_from": "2012-04-21", + "valid_to": "9999-01-31", + "type": "QSOFIELDS", + "field": "address", + "match_by": "description", + "pattern": "", + "or_rules": [ + { + "field": "qth", + "match_by": "description" + }, + { + "field": "qth", + "match_by": "pattern" + } + ], + "dxcc_filter": [ + 318, + 152, + 321 + ], + "valid_bands": [ + "80m", + "40m", + "20m", + "15m", + "10m" + ], + "emission": [ + "CW", + "PHONE", + "DIGITAL" + ], + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw", + "qsl" + ], + "total": 0, + "builtin": true + }, + "references": [ + { + "code": "AH", + "name": "Anhui", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "BJ", + "name": "Beijing", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "CQ", + "name": "Chongqing", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "FJ", + "name": "Fujian", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "GD", + "name": "Guangdong", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "GS", + "name": "Gansu", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "GX", + "name": "Guangxi", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "GZ", + "name": "Guizhou", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HA", + "name": "Henan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HB", + "name": "Hubei", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HE", + "name": "Hebei", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HI", + "name": "Hainan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HK", + "name": "Hong Kong", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HL", + "name": "Heilongjiang", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "HN", + "name": "Hunan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "JL", + "name": "Jilin", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "JS", + "name": "Jiangsu", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "pattern": "\\bJiangyin\\b", + "valid": true + }, + { + "code": "JX", + "name": "Jiangxi", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "LN", + "name": "Liaoning", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "MO", + "name": "Macau", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "pattern": "\\bMaca[uo]\\b", + "valid": true + }, + { + "code": "NM", + "name": "NeiMongol", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "NX", + "name": "Ningxia", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "QH", + "name": "Qinghai", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "SC", + "name": "Sichuan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "SD", + "name": "Shandong", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "SH", + "name": "Shanghai", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "SN", + "name": "Shaanxi", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "SX", + "name": "Shanxi", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "TJ", + "name": "Tianjin", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "TW", + "name": "Taiwan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "XJ", + "name": "Xinjiang", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "XZ", + "name": "Xizang", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "YN", + "name": "Yunnan", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + }, + { + "code": "ZJ", + "name": "Zhejiang", + "dxcc": 318, + "group": "China Provinces", + "subgrp": "", + "valid": true + } + ] + } + ] +} \ No newline at end of file