From c170d6091e32913313c564c930ad20d86bf93dcf Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 13 Jul 2026 17:38:18 +0200 Subject: [PATCH] feat: Reworked the awards logic so it is easy to add new ones. --- app.go | 289 +++++++++++++++++++++--- frontend/src/components/AwardEditor.tsx | 178 +++++++++++++-- frontend/src/lib/i18n.tsx | 34 +++ frontend/wailsjs/go/main/App.d.ts | 8 + frontend/wailsjs/go/main/App.js | 16 ++ frontend/wailsjs/go/models.ts | 57 +++++ internal/award/award.go | 130 +++++++++-- internal/award/award_test.go | 126 +++++++++++ internal/award/catalog/ddfm.json | 23 ++ internal/award/catalog/dxcc.json | 21 ++ internal/award/catalog/iota.json | 21 ++ internal/award/catalog/pota.json | 22 ++ internal/award/catalog/sota.json | 22 ++ internal/award/catalog/wac.json | 24 ++ internal/award/catalog/was.json | 27 +++ internal/award/catalog/waz.json | 23 ++ internal/award/catalog/wpx.json | 22 ++ internal/award/catalog/wwff.json | 22 ++ 18 files changed, 994 insertions(+), 71 deletions(-) create mode 100644 internal/award/catalog/ddfm.json create mode 100644 internal/award/catalog/dxcc.json create mode 100644 internal/award/catalog/iota.json create mode 100644 internal/award/catalog/pota.json create mode 100644 internal/award/catalog/sota.json create mode 100644 internal/award/catalog/wac.json create mode 100644 internal/award/catalog/was.json create mode 100644 internal/award/catalog/waz.json create mode 100644 internal/award/catalog/wpx.json create mode 100644 internal/award/catalog/wwff.json diff --git a/app.go b/app.go index e2519c3..158b714 100644 --- a/app.go +++ b/app.go @@ -3366,9 +3366,46 @@ type AwardImportResult struct { // reference list to a JSON bundle. Returns the path written, or "" if the user // cancelled. 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. +func (a *App) GetCatalogCodes() []string { + cat := award.Catalog() + out := make([]string, 0, len(cat)) + for _, e := range cat { + out = append(out, strings.ToUpper(strings.TrimSpace(e.Def.Code))) + } + return out +} + +// ExportAward writes ONE award to its own JSON file — the unit you actually share. +// The whole-catalogue bundle is a backup; a single award is what you send someone. +// +// It reads the DEFINITION AND ITS REFERENCES FROM THE DATABASE, not from the +// embedded catalog: your WAPC is only worth sharing because of the province list +// and the city regexes you added, and those live in the database. Exporting the +// catalog's version would hand over an empty shell. +func (a *App) ExportAward(code string) (string, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + return "", fmt.Errorf("no award selected") + } + return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code) +} + +// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle. +func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) { if a.awardRefs == nil { return "", fmt.Errorf("db not initialized") } + want := map[string]bool{} + for _, c := range codes { + want[strings.ToUpper(strings.TrimSpace(c))] = true + } defs := a.awardDefs() bundle := AwardBundle{ Version: 1, @@ -3376,19 +3413,25 @@ func (a *App) ExportAwards() (string, error) { Awards: make([]AwardBundleEntry, 0, len(defs)), } for _, d := range defs { + if len(want) > 0 && !want[strings.ToUpper(d.Code)] { + continue + } refs, err := a.awardRefs.List(a.ctx, d.Code) if err != nil { return "", fmt.Errorf("list references for %s: %w", d.Code, err) } bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs}) } + if len(bundle.Awards) == 0 { + return "", fmt.Errorf("nothing to export") + } data, err := json.MarshalIndent(bundle, "", " ") if err != nil { return "", err } path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ - Title: "Export awards", - DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json", + Title: title, + DefaultFilename: defaultName, Filters: []wruntime.FileFilter{ {DisplayName: "Award bundle (*.json)", Pattern: "*.json"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, @@ -3408,51 +3451,177 @@ func (a *App) ExportAwards() (string, error) { // replaces that award's list. Returns counts; the user cancelling yields a // zero result and no error. func (a *App) ImportAwards() (AwardImportResult, error) { - var res AwardImportResult + // Kept for the old call sites: inspect, then apply with the default decision. + // Anything that already exists is SKIPPED, never silently replaced. + p, err := a.InspectAwardImport() + if err != nil || p.Path == "" { + return AwardImportResult{}, err + } + dec := map[string]string{} + for _, e := range p.Awards { + if e.Exists { + dec[e.Code] = "skip" + } else { + dec[e.Code] = "replace" + } + } + return a.ApplyAwardImport(p.Path, dec) +} + +// AwardImportPreviewEntry describes one award found in a file to import. +type AwardImportPreviewEntry struct { + Code string `json:"code"` + Name string `json:"name"` + References int `json:"references"` + Exists bool `json:"exists"` // an award with this code is already installed + MineName string `json:"mine_name"` // the name of the one you already have + MineRefs int `json:"mine_refs"` // how many references yours carries + Protected bool `json:"protected"` // yours is a protected built-in +} + +// AwardImportPreview is what the file holds, and what would collide. +type AwardImportPreview struct { + Path string `json:"path"` + Awards []AwardImportPreviewEntry `json:"awards"` +} + +// InspectAwardImport opens a bundle and reports what it contains WITHOUT touching +// anything — so the UI can ask before overwriting. +// +// The import used to merge by code with "imported wins", silently. Import a WAPC +// someone shared and YOUR WAPC — its province list, its city regexes, its band +// scope — was destroyed without a word. Sharing awards is precisely the feature we +// want people to use, so it must not be a data-loss trap: we look first, then ask. +func (a *App) InspectAwardImport() (AwardImportPreview, error) { + var p AwardImportPreview if a.awardRefs == nil || a.settings == nil { - return res, fmt.Errorf("db not initialized") + return p, fmt.Errorf("db not initialized") } path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ - Title: "Import awards", + Title: "Import award(s)", Filters: []wruntime.FileFilter{ {DisplayName: "Award bundle (*.json)", Pattern: "*.json"}, {DisplayName: "All files (*.*)", Pattern: "*.*"}, }, }) if err != nil || path == "" { - return res, err + return p, err } - data, err := os.ReadFile(path) + bundle, err := readAwardBundle(path) if err != nil { - return res, fmt.Errorf("read %s: %w", path, err) + return p, err } - var bundle AwardBundle - if err := json.Unmarshal(data, &bundle); err != nil { - return res, fmt.Errorf("parse award bundle: %w", err) - } - if len(bundle.Awards) == 0 { - return res, fmt.Errorf("no awards in file") - } - - // Merge definitions: upsert by code (imported wins), keep the rest. - defs := a.awardDefs() - byCode := map[string]int{} - for i, d := range defs { - byCode[strings.ToUpper(d.Code)] = i + mine := map[string]award.Def{} + for _, d := range a.awardDefs() { + mine[strings.ToUpper(d.Code)] = d } + p.Path = path for _, e := range bundle.Awards { code := strings.ToUpper(strings.TrimSpace(e.Def.Code)) if code == "" { continue } - if i, ok := byCode[code]; ok { - defs[i] = e.Def - } else { - byCode[code] = len(defs) - defs = append(defs, e.Def) + entry := AwardImportPreviewEntry{ + Code: code, Name: e.Def.Name, References: len(e.References), } - res.Awards++ + if d, ok := mine[code]; ok { + entry.Exists = true + entry.MineName = d.Name + entry.Protected = d.Protected + if refs, err := a.awardRefs.List(a.ctx, code); err == nil { + entry.MineRefs = len(refs) + } + } + p.Awards = append(p.Awards, entry) } + if len(p.Awards) == 0 { + return p, fmt.Errorf("no awards in file") + } + return p, nil +} + +// ApplyAwardImport imports a bundle, applying the operator's decision per code: +// +// "replace" — take theirs, overwriting mine +// "skip" — keep mine, ignore the import +// "copy" — install theirs under a free code (WAPC-2), so both exist and can be +// compared before one is deleted +// +// "copy" is the one that earns its keep: it lets you LOOK before choosing, without +// losing anything either way. +func (a *App) ApplyAwardImport(path string, decisions map[string]string) (AwardImportResult, error) { + var res AwardImportResult + if a.awardRefs == nil || a.settings == nil { + return res, fmt.Errorf("db not initialized") + } + bundle, err := readAwardBundle(path) + if err != nil { + return res, err + } + + defs := a.awardDefs() + idx := map[string]int{} + for i, d := range defs { + idx[strings.ToUpper(d.Code)] = i + } + // refsToWrite is applied only after the definitions save cleanly — a half-done + // import that swapped the reference list but not the definition would leave the + // award quietly broken. + type pending struct { + code string + refs []awardref.Ref + } + var refsToWrite []pending + + for _, e := range bundle.Awards { + code := strings.ToUpper(strings.TrimSpace(e.Def.Code)) + if code == "" { + continue + } + decision := strings.ToLower(strings.TrimSpace(decisions[code])) + _, exists := idx[code] + if !exists { + decision = "replace" // nothing to collide with + } + switch decision { + case "skip", "": + continue + + case "copy": + newCode := freeAwardCode(code, idx) + d := e.Def + d.Code = newCode + // An imported copy is NOT a protected built-in: the operator must be able + // to delete it once they've decided which one they keep. + d.Builtin, d.Protected = false, false + idx[newCode] = len(defs) + defs = append(defs, d) + res.Awards++ + if len(e.References) > 0 { + refsToWrite = append(refsToWrite, pending{newCode, e.References}) + } + + default: // "replace" + d := e.Def + d.Code = code + if i, ok := idx[code]; ok { + defs[i] = d + } else { + idx[code] = len(defs) + defs = append(defs, d) + } + res.Awards++ + // An entry with NO references leaves the existing list alone — otherwise + // importing a def-only export would wipe a seeded built-in list. + if len(e.References) > 0 { + refsToWrite = append(refsToWrite, pending{code, e.References}) + } + } + } + if res.Awards == 0 { + return res, nil // everything skipped + } + if migrated, changed := award.Migrate(defs); changed { defs = migrated } @@ -3460,22 +3629,54 @@ func (a *App) ImportAwards() (AwardImportResult, error) { if err := a.settings.SetGlobal(a.ctx, keyAwardDefs, string(b)); err != nil { return res, fmt.Errorf("save award defs: %w", err) } - - // Replace reference lists for entries that carry them (skip empty so we - // don't wipe built-in-seeded lists for a def exported without refs). - for _, e := range bundle.Awards { - if len(e.References) == 0 { - continue - } - n, err := a.ReplaceAwardReferences(e.Def.Code, e.References) + for _, p := range refsToWrite { + n, err := a.ReplaceAwardReferences(p.code, p.refs) if err != nil { - return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err) + return res, fmt.Errorf("import references for %s: %w", p.code, err) } res.References += n } + a.invalidateAwardStats() return res, nil } +// readAwardBundle parses a bundle file. Size-capped: an award bundle is a few +// hundred kB at most, and an unbounded read of a file someone sent you is not a +// risk worth taking. +func readAwardBundle(path string) (AwardBundle, error) { + var bundle AwardBundle + fi, err := os.Stat(path) + if err != nil { + return bundle, fmt.Errorf("read %s: %w", path, err) + } + const maxBundle = 32 << 20 // 32 MB + if fi.Size() > maxBundle { + return bundle, fmt.Errorf("%s is too large for an award bundle (%d bytes)", path, fi.Size()) + } + data, err := os.ReadFile(path) + if err != nil { + return bundle, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(data, &bundle); err != nil { + return bundle, fmt.Errorf("parse award bundle: %w", err) + } + if len(bundle.Awards) == 0 { + return bundle, fmt.Errorf("no awards in file") + } + return bundle, nil +} + +// freeAwardCode returns the first unused "CODE-n" (WAPC-2, WAPC-3, …). +func freeAwardCode(code string, taken map[string]int) string { + for n := 2; n < 100; n++ { + c := fmt.Sprintf("%s-%d", code, n) + if _, clash := taken[c]; !clash { + return c + } + } + return code + "-COPY" +} + // builtinRefsVersion is bumped whenever the built-in reference data changes // (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // derived lists. Bump this after correcting BuiltinRefs / the DXCC name table. @@ -3508,6 +3709,22 @@ func (a *App) seedBuiltinReferences() { if firstRun && counts[code] > 0 { continue // don't overwrite an existing list on a fresh install } + // A catalog award that SHIPS its own reference list wins: that is how an + // award added as a JSON file (a shared WAPC, with its provinces and their + // 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 { + 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 { + applog.Printf("award-refs: seeded %s from the catalog — %d references", code, n) + continue + } + } else if err != nil { + applog.Printf("award-refs: %s catalog references are malformed: %v", code, err) + } + } if refs, ok := awardref.BuiltinRefs(code); ok { if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil { applog.Printf("award-refs: seeded %s — %d references", code, n) diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index ab7ecb5..af27f74 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 } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, Share2 } 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, + ExportAwards, ImportAwards, ExportAward, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, } from '../../wailsjs/go/main/App'; // Above this many references the editor stops loading the whole list and @@ -177,6 +177,29 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { loadMeta(); }, [open]); + // Codes present in the SHIPPED catalog. Anything in the database that isn't in + // here exists only on this machine — it is yours alone, and a reinstall loses it + // unless it has been exported. The list flags those. + const [catalogCodes, setCatalogCodes] = useState([]); + useEffect(() => { + if (!open) return; + GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {}); + }, [open]); + + // Pending import awaiting the operator's decision on the awards that collide. + type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean }; + type ImportPreview = { path: string; awards: ImportEntry[] }; + const [importPreview, setImportPreview] = useState(null); + const [decisions, setDecisions] = useState>({}); + useEffect(() => { + if (!importPreview) return; + // Default to the SAFE choice: keep what you have. An import must never destroy + // an award because the operator clicked through a dialog without reading it. + const d: Record = {}; + for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace'; + setDecisions(d); + }, [importPreview]); + const cur = defs[sel]; const patch = (p: Partial) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d))); const toggleIn = (key: keyof AwardDef, v: string) => { @@ -215,17 +238,51 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { if (p) setErr(t('awed.exportedTo', { path: p })); } catch (e: any) { setErr(String(e?.message ?? e)); } } - // Import an award bundle: definitions are upserted by code, reference lists - // replaced. Reloads the editor afterwards. + // 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 + // someone shared and YOUR WAPC (its province list, its city regexes) was + // destroyed without a word. Sharing awards is exactly what we want people to do, + // so it must not be a data-loss trap. async function importAwards() { setErr(''); try { - const r: any = await ImportAwards(); - if (!r || (!r.awards && !r.references)) return; // cancelled + const p: any = await InspectAwardImport(); + if (!p?.path) return; // cancelled + const clashes = (p.awards ?? []).filter((e: any) => e.exists); + if (clashes.length === 0) { + // Nothing collides — nothing to ask. This is the common case. + const dec: Record = {}; + for (const e of p.awards) dec[e.code] = 'replace'; + await applyImport(p.path, dec); + return; + } + setImportPreview(p); // → the collision dialog decides + } catch (e: any) { setErr(String(e?.message ?? e)); } + } + + async function applyImport(path: string, decisions: Record) { + try { + const r: any = await ApplyAwardImport(path, decisions); + setImportPreview(null); const [d] = await Promise.all([GetAwardDefs(), loadMeta()]); setDefs((d ?? []) as any); setSel(0); onSaved(); - setErr(t('awed.importedMsg', { awards: r.awards, references: r.references })); + if (r?.awards || r?.references) { + setErr(t('awed.importedMsg', { awards: r.awards, references: r.references })); + } } catch (e: any) { setErr(String(e?.message ?? e)); } } async function updateList(code: string) { @@ -261,15 +318,28 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
- {filtered.map(({ d, i }) => ( - - ))} + {filtered.map(({ d, i }) => { + // An award that is NOT in the shipped catalog exists only in THIS + // database: nobody else has it, and a reinstall loses it unless it + // has been exported. That deserves to be visible at a glance, not + // discovered the hard way. + const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase()); + return ( + + ); + })}
patch({ description: e.target.value })} /> @@ -445,6 +526,11 @@ 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. */} + @@ -456,6 +542,66 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { + + {/* Collision dialog. The import cannot silently replace an award you already + have — importing a shared WAPC used to destroy yours (province list, city + regexes, band scope) without a word. You decide, per award. */} + {importPreview && ( + setImportPreview(null)}> + + + {t('awed.importCollisionTitle')} + +

{t('awed.importCollisionHint')}

+
+ {importPreview.awards.map((e) => ( +
+
+ {e.code} + {e.name} + + {t('awed.importRefs', { n: e.references })} + +
+ + {!e.exists ? ( +

{t('awed.importNew')}

+ ) : ( + <> +

+ {t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })} + {e.protected && ` · ${t('awed.importProtected')}`} +

+
+ {([ + ['skip', t('awed.importKeepMine')], + ['replace', t('awed.importReplace')], + ['copy', t('awed.importCopy', { code: e.code })], + ] as [string, string][]).map(([v, label]) => ( + + ))} +
+ + )} +
+ ))} +
+ + + + +
+
+ )} ); } diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index bdf4e63..de9d804 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -248,6 +248,23 @@ const en: Dict = { 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', '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.builtin': 'Built-in', + '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.', + 'awed.exportOneTitle': 'Export THIS award on its own (definition + references + their regexes) — the file you send someone', + 'awed.importCollisionTitle': 'Some of these awards already exist', + 'awed.importCollisionHint': 'Nothing is replaced unless you say so. Import as a copy installs theirs alongside yours, so you can compare before deleting one.', + 'awed.importRefs': '{n} reference(s)', + 'awed.importNew': 'New — will be added.', + 'awed.importExists': 'You already have "{name}" with {n} reference(s).', + 'awed.importProtected': 'built-in', + 'awed.importKeepMine': 'Keep mine', + 'awed.importReplace': 'Replace mine', + 'awed.importCopy': 'Import as {code}-2', // QSO modals (context menu / bulk edit / QSL manager / QSO edit) 'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…', 'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}', @@ -472,6 +489,23 @@ const fr: Dict = { 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', '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.builtin': 'Intégré', + '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.', + 'awed.exportOneTitle': 'Exporter CE diplôme seul (définition + références + leurs regex) — le fichier que tu envoies à quelqu’un', + 'awed.importCollisionTitle': 'Certains de ces diplômes existent déjà', + 'awed.importCollisionHint': 'Rien n’est remplacé sans ton accord. « Importer en copie » installe le sien à côté du tien : tu compares, puis tu supprimes l’un des deux.', + 'awed.importRefs': '{n} référence(s)', + 'awed.importNew': 'Nouveau — sera ajouté.', + 'awed.importExists': 'Tu as déjà « {name} » avec {n} référence(s).', + 'awed.importProtected': 'intégré', + 'awed.importKeepMine': 'Garder le mien', + 'awed.importReplace': 'Remplacer le mien', + 'awed.importCopy': 'Importer en {code}-2', 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', 'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}', 'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index e4e5e1f..b301685 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -34,6 +34,8 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise; export function AntGeniusDeselect(arg1:number):Promise; +export function ApplyAwardImport(arg1:string,arg2:Record):Promise; + export function ApplyAwardPreset(arg1:string,arg2:string):Promise; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array):Promise; @@ -150,6 +152,8 @@ export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array):Promise; +export function ExportAward(arg1:string):Promise; + export function ExportAwards():Promise; export function ExportCabrillo(arg1:string):Promise; @@ -286,6 +290,8 @@ export function GetCATState():Promise; export function GetCWDecoderPitch():Promise; +export function GetCatalogCodes():Promise>; + export function GetChatHistory(arg1:number):Promise>; export function GetClublogCtyInfo():Promise; @@ -470,6 +476,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise; +export function InspectAwardImport():Promise; + export function LaunchAutostartProgram(arg1:string):Promise; export function LaunchAutostartPrograms():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 43528ab..894cb63 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -26,6 +26,10 @@ export function AntGeniusDeselect(arg1) { return window['go']['main']['App']['AntGeniusDeselect'](arg1); } +export function ApplyAwardImport(arg1, arg2) { + return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2); +} + export function ApplyAwardPreset(arg1, arg2) { return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); } @@ -258,6 +262,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) { return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3); } +export function ExportAward(arg1) { + return window['go']['main']['App']['ExportAward'](arg1); +} + export function ExportAwards() { return window['go']['main']['App']['ExportAwards'](); } @@ -530,6 +538,10 @@ export function GetCWDecoderPitch() { return window['go']['main']['App']['GetCWDecoderPitch'](); } +export function GetCatalogCodes() { + return window['go']['main']['App']['GetCatalogCodes'](); +} + export function GetChatHistory(arg1) { return window['go']['main']['App']['GetChatHistory'](arg1); } @@ -898,6 +910,10 @@ export function ImportAwards() { return window['go']['main']['App']['ImportAwards'](); } +export function InspectAwardImport() { + return window['go']['main']['App']['InspectAwardImport'](); +} + export function LaunchAutostartProgram(arg1) { return window['go']['main']['App']['LaunchAutostartProgram'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9f8c6e2..2095c9e 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1267,6 +1267,63 @@ export namespace main { this.enabled = source["enabled"]; } } + export class AwardImportPreviewEntry { + code: string; + name: string; + references: number; + exists: boolean; + mine_name: string; + mine_refs: number; + protected: boolean; + + static createFrom(source: any = {}) { + return new AwardImportPreviewEntry(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.code = source["code"]; + this.name = source["name"]; + this.references = source["references"]; + this.exists = source["exists"]; + this.mine_name = source["mine_name"]; + this.mine_refs = source["mine_refs"]; + this.protected = source["protected"]; + } + } + export class AwardImportPreview { + path: string; + awards: AwardImportPreviewEntry[]; + + static createFrom(source: any = {}) { + return new AwardImportPreview(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.path = source["path"]; + this.awards = this.convertValues(source["awards"], AwardImportPreviewEntry); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class AwardImportResult { awards: number; references: number; diff --git a/internal/award/award.go b/internal/award/award.go index 7ecf83c..a6dcfae 100644 --- a/internal/award/award.go +++ b/internal/award/award.go @@ -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. diff --git a/internal/award/award_test.go b/internal/award/award_test.go index 7a96a5a..f19d61d 100644 --- a/internal/award/award_test.go +++ b/internal/award/award_test.go @@ -1,6 +1,7 @@ package award import ( + "encoding/json" "sort" "testing" @@ -378,3 +379,128 @@ func TestComputePredefinedList(t *testing.T) { t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r)) } } + +// The built-in awards moved from Go code into embedded JSON (catalog/*.json). +// A refactor is only a refactor if the behaviour is identical, so pin down what +// the catalog MUST still produce — a typo in a JSON file would otherwise silently +// disable an award, and nobody would notice until a QSO stopped counting. +func TestCatalogDefaults(t *testing.T) { + defs := Defaults() + byCode := map[string]Def{} + for _, d := range defs { + byCode[d.Code] = d + } + + 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) + } + for _, c := range want { + d, ok := byCode[c] + if !ok { + t.Errorf("%s missing from the embedded catalog", c) + continue + } + // Valid=false would hide the award entirely; Builtin=false would let a + // "reset to defaults" delete it. Both are silent failures. + if !d.Valid || !d.Builtin { + t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin) + } + if d.Type == "" || d.Field == "" { + t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field) + } + if len(d.Confirm) == 0 { + t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c) + } + } + + // Spot-check the two that carry real matching logic, since a mangled escape in + // JSON is exactly the failure this test exists to catch. + if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` { + t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got) + } + if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil { + t.Errorf("DDFM pattern does not compile: %v", err) + } + if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 { + t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got) + } + if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" { + t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy) + } + if !byCode["WPX"].Dynamic { + t.Error("WPX must be dynamic — its references aren't a fixed list") + } + // Deterministic order: an embed walk that reorders would shuffle the user's list. + for i := 1; i < len(defs); i++ { + if defs[i-1].Code > defs[i].Code { + t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code) + } + } +} + +// The whole point of the catalog: an award you EXPORT from the UI can be dropped +// straight into catalog/ and shipped to everyone — no conversion step. So the +// loader must accept the exported bundle shape, and it must carry the REFERENCES, +// because a WAPC without its provinces and their city regexes is an empty shell. +func TestCatalogAcceptsExportedBundle(t *testing.T) { + // Exactly what ExportAward writes. + exported := []byte(`{ + "version": 1, + "exported_at": "2026-07-13T10:00:00Z", + "awards": [ + { + "def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true, + "type":"QSOFIELDS","field":"address","match_by":"description", + "dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34}, + "references": [ + {"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true}, + {"code":"ZJ","name":"Zhejiang","valid":true} + ] + } + ] + }`) + + var f catalogFile + if err := json.Unmarshal(exported, &f); err != nil { + t.Fatalf("an exported bundle must parse as a catalog file: %v", err) + } + if len(f.Awards) != 1 { + t.Fatalf("got %d awards, want 1", len(f.Awards)) + } + e := f.Awards[0] + if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" { + t.Errorf("definition lost in the round-trip: %+v", e.Def) + } + if len(e.References) == 0 { + t.Fatal("references dropped — this is the whole value of sharing an award") + } + // The per-reference regex must survive: it is what makes the award actually work. + var refs []struct { + Code string `json:"code"` + Pattern string `json:"pattern"` + } + if err := json.Unmarshal(e.References, &refs); err != nil { + t.Fatalf("references not decodable: %v", err) + } + if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` { + t.Errorf("reference regex did not survive: %+v", refs) + } + if _, err := compileAwardRE(refs[0].Pattern); err != nil { + t.Errorf("the shipped regex does not compile: %v", err) + } +} + +// A single malformed file in the catalog must not take the other awards down with +// it — one bad JSON should cost you one award, not all of them. +func TestCatalogSurvivesOneBadFile(t *testing.T) { + if len(Catalog()) < 10 { + t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog())) + } + var f catalogFile + if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil { + t.Error("expected a parse error on malformed JSON") + } + // Catalog() skips unparseable files rather than returning nil, so the others + // still load. (Verified structurally: the loader `continue`s on error.) +} diff --git a/internal/award/catalog/ddfm.json b/internal/award/catalog/ddfm.json new file mode 100644 index 0000000..039103e --- /dev/null +++ b/internal/award/catalog/ddfm.json @@ -0,0 +1,23 @@ +{ + "def": { + "code": "DDFM", + "name": "Départements Français Métropolitains", + "valid": true, + "protected": true, + "type": "QSOFIELDS", + "field": "note", + "pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b", + "dxcc_filter": [ + 227 + ], + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 96, + "builtin": true + } +} diff --git a/internal/award/catalog/dxcc.json b/internal/award/catalog/dxcc.json new file mode 100644 index 0000000..a35e4de --- /dev/null +++ b/internal/award/catalog/dxcc.json @@ -0,0 +1,21 @@ +{ + "def": { + "code": "DXCC", + "name": "DX Century Club", + "valid": true, + "protected": true, + "type": "DXCC", + "field": "dxcc", + "pattern": "", + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 340, + "builtin": true + } +} diff --git a/internal/award/catalog/iota.json b/internal/award/catalog/iota.json new file mode 100644 index 0000000..1908ec3 --- /dev/null +++ b/internal/award/catalog/iota.json @@ -0,0 +1,21 @@ +{ + "def": { + "code": "IOTA", + "name": "Islands On The Air", + "valid": true, + "protected": true, + "type": "REFERENCE", + "field": "iota", + "pattern": "", + "dynamic": true, + "dxcc_filter": null, + "confirm": [ + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 0, + "builtin": true + } +} diff --git a/internal/award/catalog/pota.json b/internal/award/catalog/pota.json new file mode 100644 index 0000000..0ee5d61 --- /dev/null +++ b/internal/award/catalog/pota.json @@ -0,0 +1,22 @@ +{ + "def": { + "code": "POTA", + "name": "Parks On The Air", + "valid": true, + "protected": true, + "type": "REFERENCE", + "field": "pota_ref", + "pattern": "", + "dynamic": true, + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 0, + "builtin": true + } +} diff --git a/internal/award/catalog/sota.json b/internal/award/catalog/sota.json new file mode 100644 index 0000000..8c372f5 --- /dev/null +++ b/internal/award/catalog/sota.json @@ -0,0 +1,22 @@ +{ + "def": { + "code": "SOTA", + "name": "Summits On The Air", + "valid": true, + "protected": true, + "type": "REFERENCE", + "field": "sota_ref", + "pattern": "", + "dynamic": true, + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 0, + "builtin": true + } +} diff --git a/internal/award/catalog/wac.json b/internal/award/catalog/wac.json new file mode 100644 index 0000000..1398504 --- /dev/null +++ b/internal/award/catalog/wac.json @@ -0,0 +1,24 @@ +{ + "def": { + "code": "WAC", + "name": "Worked All Continents", + "valid": true, + "protected": true, + "type": "QSOFIELDS", + "field": "cont", + "match_by": "code", + "exact_match": true, + "pattern": "", + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl", + "eqsl" + ], + "validate": [ + "lotw" + ], + "total": 6, + "builtin": true + } +} diff --git a/internal/award/catalog/was.json b/internal/award/catalog/was.json new file mode 100644 index 0000000..03432e9 --- /dev/null +++ b/internal/award/catalog/was.json @@ -0,0 +1,27 @@ +{ + "def": { + "code": "WAS", + "name": "Worked All States", + "valid": true, + "protected": true, + "type": "QSOFIELDS", + "field": "state", + "match_by": "code", + "exact_match": true, + "pattern": "", + "dxcc_filter": [ + 291, + 110, + 6 + ], + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 50, + "builtin": true + } +} diff --git a/internal/award/catalog/waz.json b/internal/award/catalog/waz.json new file mode 100644 index 0000000..d263a22 --- /dev/null +++ b/internal/award/catalog/waz.json @@ -0,0 +1,23 @@ +{ + "def": { + "code": "WAZ", + "name": "Worked All Zones (CQ)", + "valid": true, + "protected": true, + "type": "QSOFIELDS", + "field": "cqz", + "match_by": "code", + "exact_match": true, + "pattern": "", + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 40, + "builtin": true + } +} diff --git a/internal/award/catalog/wpx.json b/internal/award/catalog/wpx.json new file mode 100644 index 0000000..cfa3d6c --- /dev/null +++ b/internal/award/catalog/wpx.json @@ -0,0 +1,22 @@ +{ + "def": { + "code": "WPX", + "name": "Worked All Prefixes (CQ WPX)", + "valid": true, + "protected": true, + "type": "QSOFIELDS", + "field": "prefix", + "pattern": "", + "dynamic": true, + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 0, + "builtin": true + } +} diff --git a/internal/award/catalog/wwff.json b/internal/award/catalog/wwff.json new file mode 100644 index 0000000..b9a413d --- /dev/null +++ b/internal/award/catalog/wwff.json @@ -0,0 +1,22 @@ +{ + "def": { + "code": "WWFF", + "name": "World Wide Flora & Fauna", + "valid": true, + "protected": true, + "type": "REFERENCE", + "field": "wwff", + "pattern": "", + "dynamic": true, + "dxcc_filter": null, + "confirm": [ + "lotw", + "qsl" + ], + "validate": [ + "lotw" + ], + "total": 0, + "builtin": true + } +}