feat: move ADIF fields on import, for contest exports that misplace the exchange

An operator worked the RSGB IOTA contest in another logger and got back records
carrying <STATE:5>EU005: N1MM-class software stores the received exchange in the
column its contest module uses, not the one ADIF reserves. Imported verbatim,
every QSO gains a US-state field reading "EU005" and the IOTA award sees
nothing — the reference is in the log, just not where anything looks for it.

The import dialog gains optional source → destination rows, prefilled with
STATE → IOTA since that is the case that prompted it. Applied to the RAW record
before conversion, so it works for promoted columns and Extras alike and the
destination is parsed exactly as if the file had carried it there.

Two rules, both from the same principle that the file outranks our guess:
  - the source is CLEARED, so a reference does not linger in STATE where it
    would show as the contacted station's US state in the grid and every export;
  - a destination the file already filled is kept, judged against the record as
    it ARRIVED — otherwise the result would depend on Go's map iteration order,
    which is deliberately random.

The swap case in the test is what forced that second rule to be stated: "never
overwrite" and "exchange two fields" cannot both hold silently, so a destination
that is itself a mapping source is treated as an explicit swap and nothing else
is overwritten.
This commit is contained in:
2026-07-30 14:57:05 +02:00
parent d322ea9a07
commit 4d5c5c3eb3
8 changed files with 195 additions and 9 deletions
+53 -1
View File
@@ -1518,6 +1518,11 @@ export default function App() {
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true);
const [importApplyStation, setImportApplyStation] = useState(false);
// Field remapping, off unless the operator adds a row. Contest software puts
// the exchange where its own module keeps it rather than where ADIF says: the
// RSGB IOTA contest exports the island reference in STATE, which imports as a
// US state and leaves the IOTA award empty.
const [importFieldMap, setImportFieldMap] = useState<{ from: string; to: string }[]>([]);
// QRZ profile photo lightbox (full-size, in-app — not the browser).
const [photoModal, setPhotoModal] = useState<string | null>(null);
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
@@ -3365,7 +3370,13 @@ export default function App() {
setImportErrorsOpen(false);
setImportDupsOpen(false);
try {
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
const fm: Record<string, string> = {};
for (const r of importFieldMap) {
const from = r.from.trim().toUpperCase();
const to = r.to.trim().toUpperCase();
if (from && to && from !== to) fm[from] = to;
}
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation, fm);
setImportResult(res);
await refresh();
} catch (e: any) {
@@ -6323,6 +6334,47 @@ export default function App() {
</span>
</span>
</label>
{/* Field remapping. Hidden behind a link until asked for: it is the
rare case, and an import dialog that opens onto empty mapping
rows suggests something is required when nothing is. */}
<div className="pt-1 border-t mt-1">
{importFieldMap.length === 0 ? (
<button type="button"
className="text-xs text-primary hover:underline"
onClick={() => setImportFieldMap([{ from: 'STATE', to: 'IOTA' }])}>
{t('imp.mapAdd')}
</button>
) : (
<div className="space-y-1.5">
<div className="text-sm">{t('imp.mapTitle')}</div>
<div className="text-xs text-muted-foreground">{t('imp.mapDesc')}</div>
{importFieldMap.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<Input
value={row.from}
placeholder="STATE"
className="h-7 text-xs font-mono uppercase"
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))}
/>
<span className="text-muted-foreground text-xs"></span>
<Input
value={row.to}
placeholder="IOTA"
className="h-7 text-xs font-mono uppercase"
onChange={(e) => setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))}
/>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs"
onClick={() => setImportFieldMap((m) => m.filter((_, j) => j !== i))}></Button>
</div>
))}
<button type="button"
className="text-xs text-primary hover:underline"
onClick={() => setImportFieldMap((m) => [...m, { from: '', to: '' }])}>
{t('imp.mapMore')}
</button>
</div>
)}
</div>
</div>
<DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>{t('imp.cancel')}</Button>
+2 -2
View File
@@ -135,7 +135,7 @@ const en: Dict = {
'imp.ctyDesc': "Recompute Country, DXCC & CQ/ITU zones from cty.dat, overriding the file — corrects what contest software exports wrong (e.g. RG2Y as Asiatic instead of European Russia). ClubLog's DXpedition overrides are applied on top per QSO date (e.g. TO974REF → Reunion, TO2A 2012 → French Guiana) whenever the ClubLog data is downloaded. Everything else in the ADIF is kept as-is. Tip: use Update duplicates to re-fix QSOs already in your log.",
'imp.stationTitle': 'Fill my station fields from my profile',
'imp.stationDesc': "Backfill empty MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus Operator and Owner callsign from your active profile. Existing values are kept. Only STATION_CALLSIGN is left untouched so a mixed-call log isn't re-routed. It ALSO stamps your default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog, QRZ.com sent and received) on the ones the file leaves empty — a WSJT-X log carries almost none. Enable when importing your own log.",
'imp.cancel': 'Cancel', 'imp.import': 'Import',
'imp.cancel': 'Cancel', 'imp.mapAdd': 'The file puts a field in the wrong place\u2026', 'imp.mapTitle': 'Move fields on import', 'imp.mapDesc': 'Contest software stores the exchange where its own module keeps it. The RSGB IOTA contest exports the island reference in STATE \u2014 imported as-is it becomes a US state and the IOTA award stays empty. The destination is only filled where the file left it blank.', 'imp.mapMore': 'Add another', 'imp.import': 'Import',
'imp.progressTitle': 'Importing ADIF…',
'imp.progressCount': '{done} / {tot} records · {pct}%', 'imp.progressCountOnly': '{done} records…',
'imp.complete': 'Import complete.',
@@ -546,7 +546,7 @@ const fr: Dict = {
'imp.ctyDesc': "Recalcule Pays, DXCC & zones CQ/ITU depuis cty.dat, en écrasant le fichier — corrige ce que les logiciels de contest exportent mal (ex. RG2Y en Russie asiatique au lieu d'européenne). Les exceptions DXpédition de ClubLog s'appliquent par-dessus selon la date du QSO (ex. TO974REF → Réunion, TO2A 2012 → Guyane française) dès que les données ClubLog sont téléchargées. Tout le reste de l'ADIF est conservé tel quel. Astuce : utilise Mettre à jour les doublons pour re-corriger des QSO déjà dans le log.",
'imp.stationTitle': 'Remplir mes champs station depuis mon profil',
'imp.stationDesc': "Complète les champs MY_* vides (locator, rig, antenne, adresse, ville, état, comté, réf. SOTA/POTA, puissance TX…) plus Opérateur et Indicatif propriétaire depuis le profil actif. Les valeurs existantes sont conservées. Seul STATION_CALLSIGN n'est jamais touché pour ne pas re-router un log multi-indicatifs. Elle applique AUSSI tes statuts de confirmation par défaut (QSL papier, LoTW, eQSL, Club Log, HRDLog, QRZ.com envoyé et reçu) sur ceux que le fichier laisse vides — un log WSJT-X n'en contient pratiquement aucun. À activer quand tu importes ton propre log.",
'imp.cancel': 'Annuler', 'imp.import': 'Importer',
'imp.cancel': 'Annuler', 'imp.mapAdd': 'Le fichier met un champ au mauvais endroit\u2026', 'imp.mapTitle': 'D\u00e9placer des champs \u00e0 l\u2019import', 'imp.mapDesc': 'Les logiciels de concours rangent l\u2019\u00e9change l\u00e0 o\u00f9 leur module le garde. Le contest IOTA de la RSGB exporte la r\u00e9f\u00e9rence d\u2019\u00eele dans STATE \u2014 import\u00e9e telle quelle elle devient un \u00e9tat am\u00e9ricain et le dipl\u00f4me IOTA reste vide. La destination n\u2019est remplie que l\u00e0 o\u00f9 le fichier l\u2019a laiss\u00e9e vide.', 'imp.mapMore': 'Ajouter une ligne', 'imp.import': 'Importer',
'imp.progressTitle': 'Import ADIF en cours…',
'imp.progressCount': '{done} / {tot} enregistrements · {pct}%', 'imp.progressCountOnly': '{done} enregistrements…',
'imp.complete': 'Import terminé.',