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:
+53
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user