From 21a0d560de7af6392a001f63f1c0376203fc31c4 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Mon, 17 Aug 2026 10:26:13 +0200 Subject: [PATCH] feat(awards): a reference's number can be corrected in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one field the editor would not let you touch, and the one that was wrong on WAJA. Every other property of a reference — its name, pattern, entity list, validity window — was editable; the code was rendered readOnly, so correcting a number meant deleting all 47 references and importing a new list, throwing away anything the operator had adjusted in it. A rename in the store, not a delete plus an insert: everything the reference carries travels with it, which is the whole point of correcting a number rather than replacing an entry. A number already in use is refused — REPLACE INTO would have let one reference silently swallow another, discovered much later as a prefecture quietly missing from the list. The typed code is held apart from the selection. The list and every field patch key off the selected code, so editing it in place made the editor lose the reference mid-edit. SaveAwardReference now recomputes the log like Delete and Replace already did. A reference's name is what the award column SHOWS for awards displaying by name, and its pattern is part of what matches at all — so editing one changes rows, and the grid was left showing the old label until something else happened to trigger a pass. Changelog: the three TCI-sharing lines are merged into one. The server and the two fixes made to it while building are one unreleased feature, and an operator only ever meets the finished thing. The TCI-client PTT line stays separate — it is OpsLog driving a SunSDR, the other direction entirely. --- app.go | 31 +++++++ changelog.json | 18 ++-- frontend/src/components/AwardEditor.tsx | 38 +++++++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/awardref/awardref.go | 42 +++++++++ internal/awardref/rename_test.go | 112 ++++++++++++++++++++++++ 8 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 internal/awardref/rename_test.go diff --git a/app.go b/app.go index 13d4a1c..39ba7bf 100644 --- a/app.go +++ b/app.go @@ -5240,6 +5240,37 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error { } a.markAwardEdited(code) a.mirrorAwards() + // A reference's name is what the award column SHOWS for awards displaying by + // name, and its pattern is part of what matches at all — so editing one + // changes rows in the log, exactly as deleting or replacing the list does. + // Those already recomputed; this did not, and left the grid showing the old + // label until something else happened to trigger a pass. + a.recomputeAwardRefsAsync() + return nil +} + +// RenameAwardReference changes a reference's code on an award the operator +// already runs. +// +// The one field the editor could not touch, and the one that was wrong: WAJA +// shipped numbered by the Japanese state rather than by the JARL. Correcting it +// meant deleting the whole list and importing another — throwing away anything +// the operator had adjusted in it. +func (a *App) RenameAwardReference(code, oldRef, newRef string) error { + if a.awardRefs == nil { + return fmt.Errorf("db not initialized") + } + if err := a.awardRefs.Rename(a.ctx, code, oldRef, newRef); err != nil { + return err + } + a.markAwardEdited(code) + a.mirrorAwards() + // The materialised award columns hold a LABEL computed from the definition — + // the reference code for most awards, the name for those displaying by name. + // A renumbered reference changes the first kind, so the log is recomputed + // exactly as it is for every other reference-list change. + a.recomputeAwardRefsAsync() + applog.Printf("awards: %s reference %s renumbered to %s", code, oldRef, newRef) return nil } diff --git a/changelog.json b/changelog.json index 245f3f8..3cf280c 100644 --- a/changelog.json +++ b/changelog.json @@ -4,21 +4,19 @@ "date": "", "en": [ "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.", - "CAT sharing can now speak TCI instead of Hamlib, so a TCI-only program reaches whatever radio OpsLog is on.", + "CAT sharing can now speak TCI instead of Hamlib, split included, so a TCI-only program reaches whatever radio you are on.", "Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.", - "TCI: when the radio forbids transmitting, PTT now says so instead of doing nothing silently.", - "TCI sharing: the server now announces transmit permission, without which a client such as MSHV never keys at all.", - "TCI sharing: split is armed on the frequency the client asked for, whichever order it sent the two commands in.", - "WAJA carried Japan’s civil prefecture numbers instead of the JARL’s: 35 of the 47 references are renumbered." + "TCI radios: when the rig forbids transmitting, PTT says so instead of doing nothing silently.", + "WAJA carried Japan’s civil prefecture numbers instead of the JARL’s: 35 of the 47 references are renumbered.", + "Award references can be renumbered in the editor — the number was the one field it would not let you correct." ], "fr": [ "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.", - "Le partage CAT peut désormais parler TCI au lieu de Hamlib : un logiciel TCI atteint la radio, quelle qu’elle soit.", + "Le partage CAT peut désormais parler TCI au lieu de Hamlib, split compris : un logiciel TCI atteint la radio, quelle qu’elle soit.", "Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction.", - "TCI : quand la radio interdit l’émission, le PTT le dit désormais au lieu de ne rien faire en silence.", - "Partage TCI : le serveur annonce désormais l’autorisation d’émettre, sans laquelle un client comme MSHV ne passe jamais en émission.", - "Partage TCI : le split s’arme sur la fréquence demandée par le logiciel, quel que soit l’ordre de ses deux commandes.", - "WAJA portait les numéros civils des préfectures japonaises et non ceux de la JARL : 35 des 47 références sont renumérotées." + "Radios TCI : quand la radio interdit l’émission, le PTT le dit au lieu de ne rien faire en silence.", + "WAJA portait les numéros civils des préfectures japonaises et non ceux de la JARL : 35 des 47 références sont renumérotées.", + "Les références d’un diplôme se renumérotent dans l’éditeur : le numéro était le seul champ qu’il refusait de corriger." ] }, { diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index a7a44ef..b588a79 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -14,7 +14,7 @@ import { useI18n } from '@/lib/i18n'; import { GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields, GetAwardReferenceMeta, UpdateAwardReferenceList, - ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, + ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference, ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, @@ -915,6 +915,10 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on const [refs, setRefs] = useState([]); const [q, setQ] = useState(''); const [selCode, setSelCode] = useState(null); + // The code as TYPED. The list and every patch key off selCode, so editing the + // code in place would make the editor lose the reference mid-edit; the draft + // is applied as a rename when the operator saves. + const [codeDraft, setCodeDraft] = useState(''); const [busy, setBusy] = useState(false); const [bulk, setBulk] = useState(''); const [showBulk, setShowBulk] = useState(false); @@ -952,6 +956,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on } const sel = refs.find((r) => r.code === selCode) || null; + useEffect(() => { setCodeDraft(selCode ?? ''); }, [selCode]); // Large lists are already filtered by the server; small lists filter locally. const filtered = useMemo(() => { if (large) return refs; @@ -965,6 +970,27 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on try { await SaveAwardReference(code, r as any); load(); onChanged(); } catch (e: any) { setErr(String(e?.message ?? e)); } } + // Save the selected reference, renumbering it first when the code was edited. + // + // The rename has to come first and has to be a rename: saving under the new + // code would simply create a second reference and leave the old one behind, + // which is how a list quietly grows duplicates. + async function saveSelected(r: AwardRef) { + const next = codeDraft.trim().toUpperCase(); + if (!next) { setErr(t('awed.refCodeEmpty')); return; } + if (next !== r.code) { + try { + await RenameAwardReference(code, r.code, next); + } catch (e: any) { + // Most often the number is already taken by another reference. Said + // here rather than swallowed: the save has NOT happened. + setErr(String(e?.message ?? e)); + return; + } + setSelCode(next); + } + await saveRef({ ...r, code: next }); + } async function addRef() { const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase(); if (!c) return; @@ -1046,7 +1072,13 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on ) : (
- + {/* Editable, because a shipped list can be wrong about it: WAJA + went out carrying Japan's civil prefecture numbers instead of + the JARL's, and correcting that meant deleting all 47 + references and importing a new list. */} + setCodeDraft(e.target.value)} />
@@ -1084,7 +1116,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on ? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) }) : t('awed.refValidHint')}

-
+
)}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 39addbd..45ceb70 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -396,7 +396,7 @@ const en: Dict = { 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', '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.modePhone': 'Phone', 'awp.modeDigital': 'Digital', '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.prefixCol': 'Prefix', '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.refsNarrow': '…and {n} more — type in the box to narrow the list.', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found — every contact in this award’s scope already carries a reference.', '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.refValidFrom': 'Valid from', 'awed.refValidTo': 'Valid to', 'awed.refValidHint': 'A QSO only counts for this reference if it was made inside this window. Leave empty if the reference has always existed.', 'awed.refValidHintAward': 'Leave empty to use the award’s own window ({from} → {to}).', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.exportedOneTo': '{code} 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.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.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', '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.prefix': 'Prefix', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.customLabel': 'Custom source', 'awed.customField': 'ADIF field', 'awed.customValue': 'Value(s) that confirm (optional)', 'awed.customHint': 'For confirmations OpsLog has no column of its own for. Name any QSO field or ADIF tag — APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or a tag a club list stamped on import. Leave the value empty and ANY non-empty content confirms (the OpsLog marker stores a date, not Y/N); give a comma-separated list to require one of them. A custom source naming no field confirms nothing.', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', '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.grid': 'Grid', 'awed.saveReference': 'Save reference', + 'awed.addCountry': 'Add country…', 'awed.refValidFrom': 'Valid from', 'awed.refValidTo': 'Valid to', 'awed.refValidHint': 'A QSO only counts for this reference if it was made inside this window. Leave empty if the reference has always existed.', 'awed.refValidHintAward': 'Leave empty to use the award’s own window ({from} → {to}).', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.exportedOneTo': '{code} 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.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.oneRef': 'One reference per QSO', 'awed.oneRefHint': 'When several references match the same contact, assign none and list it under missing references. For awards where two entries can share a description — two DOKs called Gießen — picking one at random would write the wrong one into the log.', '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.prefix': 'Prefix', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.customLabel': 'Custom source', 'awed.customField': 'ADIF field', 'awed.customValue': 'Value(s) that confirm (optional)', 'awed.customHint': 'For confirmations OpsLog has no column of its own for. Name any QSO field or ADIF tag — APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or a tag a club list stamped on import. Leave the value empty and ANY non-empty content confirms (the OpsLog marker stores a date, not Y/N); give a comma-separated list to require one of them. A custom source naming no field confirms nothing.', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', '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.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.refCodeTip': 'The reference number. Change it to renumber this reference: everything else about it is kept, and the log is refreshed.', 'awed.refCodeEmpty': 'A reference needs a number.', 'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testAmbiguous': 'Ambiguous', 'awed.testAmbiguousHint': '— none kept, this award allows one reference per QSO. Assign the right one by hand.', 'awed.testSameAs': '+{n} other QSO(s), same result', 'awed.exportOne': 'Share {code}', @@ -817,7 +817,7 @@ const fr: Dict = { 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', '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.modePhone': 'Phonie', 'awp.modeDigital': 'Numérique', '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.prefixCol': 'Préfixe', '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.refsNarrow': '…et {n} autres — tape dans le champ pour réduire la liste.', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': 'Aucun manque trouvé — tous les contacts dans le périmètre de ce diplôme portent déjà une référence.', '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.refValidFrom': 'Valide à partir du', 'awed.refValidTo': 'Valide jusqu’au', 'awed.refValidHint': 'Un QSO ne compte pour cette référence que s’il a été fait dans cette fenêtre. Laisser vide si la référence a toujours existé.', 'awed.refValidHintAward': 'Laisser vide pour utiliser la fenêtre du diplôme ({from} → {to}).', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.exportedOneTo': '{code} exporté 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.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.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', '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.prefix': 'Préfixe', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.customLabel': 'Source personnalisée', 'awed.customField': 'Champ ADIF', 'awed.customValue': 'Valeur(s) qui confirment (facultatif)', 'awed.customHint': "Pour les confirmations dont OpsLog n'a pas de colonne dédiée. Indique n'importe quel champ de QSO ou balise ADIF — APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et TOUT contenu non vide confirme (le marqueur OpsLog stocke une date, pas un Y/N) ; mets une liste séparée par des virgules pour en exiger une. Une source personnalisée sans champ ne confirme rien.", 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', '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.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', + 'awed.addCountry': 'Ajouter un pays…', 'awed.refValidFrom': 'Valide à partir du', 'awed.refValidTo': 'Valide jusqu’au', 'awed.refValidHint': 'Un QSO ne compte pour cette référence que s’il a été fait dans cette fenêtre. Laisser vide si la référence a toujours existé.', 'awed.refValidHintAward': 'Laisser vide pour utiliser la fenêtre du diplôme ({from} → {to}).', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.exportedOneTo': '{code} exporté 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.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.oneRef': 'Une seule référence par QSO', 'awed.oneRefHint': 'Quand plusieurs références correspondent au même contact, n'+'’en affecter aucune et le lister dans les références manquantes. Pour les diplômes où deux entrées partagent une description — deux DOK nommés Gießen — en choisir une au hasard inscrirait la mauvaise dans le journal.', '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.prefix': 'Préfixe', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.customLabel': 'Source personnalisée', 'awed.customField': 'Champ ADIF', 'awed.customValue': 'Valeur(s) qui confirment (facultatif)', 'awed.customHint': "Pour les confirmations dont OpsLog n'a pas de colonne dédiée. Indique n'importe quel champ de QSO ou balise ADIF — APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et TOUT contenu non vide confirme (le marqueur OpsLog stocke une date, pas un Y/N) ; mets une liste séparée par des virgules pour en exiger une. Une source personnalisée sans champ ne confirme rien.", 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', '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.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.refCodeTip': 'Le numéro de la référence. Modifie-le pour la renuméroter : tout le reste est conservé, et le journal est rafraîchi.', 'awed.refCodeEmpty': 'Une référence a besoin d’un numéro.', 'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes', 'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testAmbiguous': 'Ambigu', 'awed.testAmbiguousHint': '— aucune retenue, ce diplôme n’admet qu’une référence par QSO. Affectez la bonne à la main.', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat', 'awed.exportOne': 'Partager {code}', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index b8c4f98..b83252d 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -861,6 +861,8 @@ export function ReloadUDPIntegrations():Promise>; export function RemovePassphrase(arg1:string):Promise; +export function RenameAwardReference(arg1:string,arg2:string,arg3:string):Promise; + export function RenameDatabase(arg1:string):Promise; export function RenameLogbook(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 8add157..6210448 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1662,6 +1662,10 @@ export function RemovePassphrase(arg1) { return window['go']['main']['App']['RemovePassphrase'](arg1); } +export function RenameAwardReference(arg1, arg2, arg3) { + return window['go']['main']['App']['RenameAwardReference'](arg1, arg2, arg3); +} + export function RenameDatabase(arg1) { return window['go']['main']['App']['RenameDatabase'](arg1); } diff --git a/internal/awardref/awardref.go b/internal/awardref/awardref.go index 89c7a81..d838812 100644 --- a/internal/awardref/awardref.go +++ b/internal/awardref/awardref.go @@ -266,6 +266,48 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error { return err } +// Rename changes a reference's CODE, keeping everything else about it. +// +// Wanted because a shipped list can simply be wrong: WAJA went out numbered by +// the Japanese state instead of by the JARL, and the only way to correct it was +// to delete all 47 references and import a new list — losing anything the +// operator had adjusted. The number is the one field an editor could not touch. +// +// A rename, not a delete plus an insert: everything the reference carries — its +// pattern, its DXCC list, its validity window — travels with it, which is the +// whole point of correcting a number rather than replacing an entry. +func (r *Repo) Rename(ctx context.Context, awardCode, oldCode, newCode string) error { + ac := strings.ToUpper(strings.TrimSpace(awardCode)) + from := strings.ToUpper(strings.TrimSpace(oldCode)) + to := strings.ToUpper(strings.TrimSpace(newCode)) + if ac == "" || from == "" || to == "" { + return fmt.Errorf("empty award or reference code") + } + if from == to { + return nil + } + // A collision would REPLACE the other reference and take its name, pattern + // and dates with it — one silently swallowing another, discovered much later + // as a reference that has quietly gone missing. + var n int + if err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM award_references WHERE award_code = ? AND ref_code = ?`, ac, to).Scan(&n); err != nil { + return err + } + if n > 0 { + return fmt.Errorf("%s already has a reference %s", ac, to) + } + res, err := r.db.ExecContext(ctx, + `UPDATE award_references SET ref_code = ? WHERE award_code = ? AND ref_code = ?`, to, ac, from) + if err != nil { + return err + } + if rows, _ := res.RowsAffected(); rows == 0 { + return fmt.Errorf("%s has no reference %s", ac, from) + } + return nil +} + // Delete removes one reference from an award. func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error { _, err := r.db.ExecContext(ctx, diff --git a/internal/awardref/rename_test.go b/internal/awardref/rename_test.go new file mode 100644 index 0000000..423a356 --- /dev/null +++ b/internal/awardref/rename_test.go @@ -0,0 +1,112 @@ +package awardref + +import ( + "context" + "path/filepath" + "testing" + + "hamlog/internal/db" +) + +func renameRepo(t *testing.T) *Repo { + t.Helper() + conn, err := db.Open(filepath.Join(t.TempDir(), "a.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return NewRepo(conn) +} + +// Correcting a reference's number must keep the reference. +// +// WAJA shipped numbered by the Japanese state instead of by the JARL, and until +// now the only way to fix that was to delete all 47 references and import a new +// list — losing anything the operator had adjusted. A rename keeps the pattern, +// the entity list and the validity window, because a wrong NUMBER is all that +// was wrong. +func TestRenameKeepsEverythingButTheCode(t *testing.T) { + r := renameRepo(t) + ctx := context.Background() + if err := r.Upsert(ctx, "WAJA", Ref{ + Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true, + DXCCList: []int{339}, ValidFrom: "1970-01-01", + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := r.Rename(ctx, "WAJA", "13", "10"); err != nil { + t.Fatalf("rename: %v", err) + } + + refs, err := r.List(ctx, "WAJA") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(refs) != 1 { + t.Fatalf("WAJA holds %d references after a rename, want 1 — it was copied, not renamed", len(refs)) + } + got := refs[0] + if got.Code != "10" { + t.Errorf("code = %q, want 10", got.Code) + } + if got.Name != "Tokyo" || got.Pattern != `\bTok[iy]o\b` { + t.Errorf("the reference lost what it carried: name=%q pattern=%q", got.Name, got.Pattern) + } + if len(got.DXCCList) != 1 || got.DXCCList[0] != 339 || got.ValidFrom != "1970-01-01" { + t.Errorf("the reference lost its entity list or dates: %+v", got) + } +} + +// A number already in use must be refused. Left to REPLACE, the rename would +// take the other reference's name, pattern and dates with it — one entry +// silently swallowing another, found much later as a prefecture that has +// quietly gone missing from the list. +func TestRenameRefusesANumberAlreadyTaken(t *testing.T) { + r := renameRepo(t) + ctx := context.Background() + if err := r.Upsert(ctx, "WAJA", Ref{Code: "10", Name: "Gunma", Valid: true}); err != nil { + t.Fatalf("seed: %v", err) + } + if err := r.Upsert(ctx, "WAJA", Ref{Code: "13", Name: "Tokyo", Valid: true}); err != nil { + t.Fatalf("seed: %v", err) + } + + if err := r.Rename(ctx, "WAJA", "13", "10"); err == nil { + t.Fatal("renaming onto an existing number was accepted — one reference would have eaten the other") + } + refs, _ := r.List(ctx, "WAJA") + if len(refs) != 2 { + t.Fatalf("WAJA holds %d references, want both still there", len(refs)) + } +} + +// Renaming something that is not there is an error, not a silent no-op: it +// means the editor and the store disagree about what the award holds. +func TestRenameAnUnknownReferenceFails(t *testing.T) { + r := renameRepo(t) + if err := r.Rename(context.Background(), "WAJA", "99", "10"); err == nil { + t.Error("renaming a reference the award does not have was accepted") + } +} + +// Codes are stored upper-cased, so a rename must compare the same way — else +// "eu-048" onto "EU-048" looks like a move and is really the same reference, +// which the collision check has to catch. +func TestRenameIsCaseInsensitive(t *testing.T) { + r := renameRepo(t) + ctx := context.Background() + if err := r.Upsert(ctx, "IOTA", Ref{Code: "EU-048", Name: "Belle-Ile", Valid: true}); err != nil { + t.Fatalf("seed: %v", err) + } + if err := r.Rename(ctx, "iota", "eu-048", "eu-048"); err != nil { + t.Errorf("renaming a reference to itself in another case failed: %v", err) + } + if err := r.Rename(ctx, "IOTA", "eu-048", "eu-049"); err != nil { + t.Fatalf("rename: %v", err) + } + refs, _ := r.List(ctx, "IOTA") + if len(refs) != 1 || refs[0].Code != "EU-049" { + t.Errorf("references = %+v, want the one renamed to EU-049 and upper-cased", refs) + } +}