From 8538f482592c2bb61b4f00d4abf3a48fb898026f Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Jul 2026 14:20:30 +0200 Subject: [PATCH] feat: Publish-to-catalog export for awards (edit in UI, version, ship to team) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ExportAwardForCatalog(code, version): writes the selected award as a catalog-format JSON ({"def":…,"references":…}) stamped with a version and with user_edited cleared, ready to paste over internal/award/catalog/.json. A new release then propagates the change to the whole team via mergeCatalog — unedited copies auto-upgrade, edited ones are offered the update. UI: a version input + "Publish to catalog…" button in the award editor footer, defaulting to one past the award's current version. --- app.go | 59 +++++++++++++++++++++++++ frontend/src/components/AwardEditor.tsx | 32 +++++++++++++- frontend/src/lib/i18n.tsx | 8 ++++ frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/app.go b/app.go index 9f96858..c74c84a 100644 --- a/app.go +++ b/app.go @@ -4203,6 +4203,65 @@ func (a *App) ExportAward(code string) (string, error) { return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code) } +// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the +// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}). +// +// The workflow it enables: edit an award in the UI, call this with the NEXT +// version number, and paste the file over that award's catalog JSON. A new OpsLog +// release then carries the change to the whole team: every operator whose copy is +// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs +// but are offered the update. +// +// Two things a plain export/mirror can't do and this does, both essential for a +// catalog file: it STAMPS the award's Version (the UI save deliberately never +// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is +// NOT pre-flagged as the operator's own work — which would otherwise freeze it out +// of every future catalog update. +func (a *App) ExportAwardForCatalog(code string, version int) (string, error) { + if a.awardRefs == nil || a.ctx == nil { + return "", fmt.Errorf("db not initialized") + } + code = strings.ToUpper(strings.TrimSpace(code)) + var def award.Def + found := false + for _, d := range a.awardDefs() { + if strings.EqualFold(strings.TrimSpace(d.Code), code) { + def, found = d, true + break + } + } + if !found { + return "", fmt.Errorf("unknown award %q", code) + } + def.Version = version + def.UserEdited = false // a catalog seed is not "the operator's edit" + def.Builtin = true + refs, err := a.awardRefs.List(a.ctx, code) + if err != nil { + return "", fmt.Errorf("load references: %w", err) + } + entry := struct { + Def award.Def `json:"def"` + References []awardref.Ref `json:"references,omitempty"` + }{Def: def, References: refs} + b, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return "", err + } + path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + DefaultFilename: strings.ToLower(code) + ".json", + Title: "Export " + code + " for the catalog", + Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}, + }) + if err != nil || strings.TrimSpace(path) == "" { + return "", err + } + if err := os.WriteFile(path, b, 0o644); err != nil { + return "", err + } + return path, nil +} + // 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 { diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 4a25341..55c3fb7 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -19,6 +19,7 @@ import { ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder, + ExportAwardForCatalog, GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward, } from '../../wailsjs/go/main/App'; @@ -37,7 +38,7 @@ export type AwardDef = { or_rules?: AwardOrRule[]; dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean; - total: number; builtin?: boolean; + total: number; builtin?: boolean; version?: number; }; type AwardOrRule = { @@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { const [search, setSearch] = useState(''); const [updating, setUpdating] = useState(null); const [err, setErr] = useState(''); + // Version to stamp into a "publish for catalog" export — defaults to one past + // the selected award's current version whenever the selection changes. + const [catVer, setCatVer] = useState('1'); // The err banner doubles as a success/notice area (export path, import counts, // "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay @@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { const cur = defs[sel]; const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null; + useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]); // ── Award tester: run the award's rules against a real QSO and show every step. type Rejected = { candidate: string; reason: string }; @@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { if (p) setErr(t('awed.exportedTo', { path: p })); } catch (e: any) { setErr(String(e?.message ?? e)); } } + // Export the SELECTED award as a catalog-ready JSON, stamped with a version, to + // paste over internal/award/catalog/.json. A new release then ships it to + // the whole team (unedited copies auto-upgrade; edited ones are offered it). + async function exportForCatalog() { + setErr(''); + if (!cur) return; + const v = Math.trunc(Number(catVer)); + if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; } + try { + const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v); + if (p) setErr(t('awed.catalogExportedTo', { 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 @@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { title={t('awed.awardsFolderTip')}> {t('awed.awardsFolder')} + {/* Publish the selected award to the catalog: stamp a version and write a + file to paste over internal/award/catalog/.json, so a release + ships your change to the whole team. */} + {cur && ( +
+ setCatVer(e.target.value)} + className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" /> + +
+ )}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index c1acf12..dc57e46 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -307,6 +307,10 @@ const en: Dict = { '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.catalogPublish': 'Publish to catalog…', + 'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.', + 'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/.json, then build and release.', + 'awed.catalogBadVersion': 'Enter a version number (1 or more).', '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.', @@ -598,6 +602,10 @@ const fr: Dict = { '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.catalogPublish': 'Publier au catalogue…', + 'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.", + 'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/.json, puis build et release.', + 'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).', 'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.', 'awed.protectedFlag': 'Protégé', 'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 42e6d4d..c687633 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -168,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array): export function ExportAward(arg1:string):Promise; +export function ExportAwardForCatalog(arg1:string,arg2:number):Promise; + export function ExportAwards():Promise; export function ExportCabrillo(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3527173..3a3ab76 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -294,6 +294,10 @@ export function ExportAward(arg1) { return window['go']['main']['App']['ExportAward'](arg1); } +export function ExportAwardForCatalog(arg1, arg2) { + return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2); +} + export function ExportAwards() { return window['go']['main']['App']['ExportAwards'](); }