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 {
+9 -19
View File
@@ -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) {
<DialogFooter className="px-5 py-3 border-t !flex-row">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> {t('awed.resetDefaults')}</Button>
{/* Share ONE award — the unit you actually send someone. The bundle
below is a backup, which is a different job. */}
<Button variant="outline" onClick={exportOne} disabled={!cur?.code} title={t('awed.exportOneTitle')}>
<Share2 className="size-3.5 mr-1" /> {t('awed.exportOne', { code: cur?.code ?? '' })}
</Button>
<Button variant="outline" onClick={exportAwards} title={t('awed.exportTitle')}>
<Download className="size-3.5 mr-1" /> {t('awed.export')}
</Button>
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
</Button>
{/* 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. */}
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+6 -2
View File
@@ -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 nexiste que dans TA base — il nest pas livré avec OpsLog. Personne dautre ne la, et une réinstallation le perd si tu ne lexportes 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 lutilisateur — alors que tu las livré.',
'awed.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
+4
View File
@@ -60,6 +60,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function AwardsFolder():Promise<string>;
export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -554,6 +556,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>;
export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>;
+8
View File
@@ -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);
}
+6 -2
View File
@@ -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]
+432
View File
@@ -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
}
]
}
]
}
+335
View File
@@ -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
}
]
}
]
}