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) + } +}