diff --git a/app.go b/app.go index a20309f..edf3843 100644 --- a/app.go +++ b/app.go @@ -5925,11 +5925,17 @@ func (a *App) OpenADIFFile() (string, error) { // Log4OM / LoTW without clobbering fields the file omits // - "all" : insert every record, duplicates included // +// fieldMap moves ADIF fields before conversion (source → destination, ADIF tag +// names in any case). Contest exports put the exchange where their module keeps +// it rather than where ADIF says: the RSGB IOTA contest arrives with the island +// reference in STATE, which imports as a US state and leaves the IOTA award +// empty. The destination is only filled when the file left it blank. +// // applyCty, when true, recomputes country / continent / DXCC / CQ / ITU from // cty.dat for every record, overriding what the file carries — corrects the // wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic // Russia). Everything else in the ADIF is still preserved verbatim. -func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) { +func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool, fieldMap map[string]string) (adif.ImportResult, error) { if a.qso == nil { return adif.ImportResult{}, fmt.Errorf("db not initialized") } @@ -5943,6 +5949,18 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio // descriptive metadata and safe to fill (identity fields are still left // alone, see applyStationDefaults). im := &adif.Importer{Repo: a.qso} + if len(fieldMap) > 0 { + lower := make(map[string]string, len(fieldMap)) + for from, to := range fieldMap { + from = strings.ToLower(strings.TrimSpace(from)) + to = strings.ToLower(strings.TrimSpace(to)) + if from != "" && to != "" && from != to { + lower[from] = to + } + } + im.FieldMap = lower + applog.Printf("import: field mapping %v", lower) + } switch dupMode { case "update": im.UpdateDuplicates = true diff --git a/changelog.json b/changelog.json index d1954aa..b2801f9 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,8 @@ "Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT.", "Icom and Xiegu: a CI-V frame read at the wrong offset no longer turns into a frequency. Values such as 16445550000 Hz appeared in the status bar while the rig sat on 145.550 MHz — the decoder treated non-decimal bytes as digits. Such frames are now refused and the last good reading stands.", "Settings → CAT can log the CI-V protocol: every frame to and from an Icom or Xiegu, as hex. For reporting a radio that answers oddly — a button that does the wrong thing, a frequency that jumps — where a description alone leaves the cause to guesswork.", - "A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window." + "A crash in the window no longer leaves a blank screen. The error is written to the app log and shown on screen, selectable, with a button to copy it and one to reload — so a fault can be reported with its cause instead of a description of an empty window.", + "ADIF import can move fields as it reads them. Contest software stores the exchange where its own module keeps it — the RSGB IOTA contest exports the island reference in STATE, which imports as a US state and leaves the IOTA award empty. Add a STATE → IOTA row in the import dialog and it lands where the award looks." ], "fr": [ "Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.", @@ -20,7 +21,8 @@ "CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT.", "Icom et Xiegu : une trame CI-V lue au mauvais décalage ne se transforme plus en fréquence. Des valeurs comme 16445550000 Hz apparaissaient dans la barre d'état alors que la radio était sur 145,550 MHz — le décodeur prenait des octets non décimaux pour des chiffres. Ces trames sont désormais refusées et la dernière lecture valable est conservée.", "Réglages → CAT permet de journaliser le protocole CI-V : chaque trame échangée avec un Icom ou un Xiegu, en hexadécimal. Pour signaler une radio qui répond de travers — un bouton qui fait autre chose, une fréquence qui saute — là où une description seule laisse la cause à la devinette.", - "Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide." + "Un plantage de la fenêtre ne laisse plus un écran vide. L'erreur est écrite dans le journal et affichée à l'écran, sélectionnable, avec un bouton pour la copier et un pour recharger — de quoi signaler un défaut avec sa cause plutôt qu'une description de fenêtre vide.", + "L'import ADIF peut déplacer des champs à la lecture. Les logiciels de concours rangent l'échange là où leur module le garde — le contest IOTA de la RSGB exporte la référence d'île dans STATE, importée comme un état américain, et le diplôme IOTA reste vide. Une ligne STATE → IOTA dans la fenêtre d'import et la référence arrive là où le diplôme la cherche." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b97b32..fdf89c3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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 = {}; + 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() { + {/* 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. */} +
+ {importFieldMap.length === 0 ? ( + + ) : ( +
+
{t('imp.mapTitle')}
+
{t('imp.mapDesc')}
+ {importFieldMap.map((row, i) => ( +
+ setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, from: e.target.value } : r))} + /> + + setImportFieldMap((m) => m.map((r, j) => j === i ? { ...r, to: e.target.value } : r))} + /> + +
+ ))} + +
+ )} +
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 5cda0a7..78ab513 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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é.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 99c7f0c..6104960 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -589,7 +589,7 @@ export function IcomStopCW():Promise; export function IcomTune():Promise; -export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise; +export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean,arg5:Record):Promise; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 292da39..2edfb59 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1126,8 +1126,8 @@ export function IcomTune() { return window['go']['main']['App']['IcomTune'](); } -export function ImportADIF(arg1, arg2, arg3, arg4) { - return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4); +export function ImportADIF(arg1, arg2, arg3, arg4, arg5) { + return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4, arg5); } export function ImportAwardReferencesText(arg1, arg2) { diff --git a/internal/adif/import.go b/internal/adif/import.go index 6a69407..e2bee22 100644 --- a/internal/adif/import.go +++ b/internal/adif/import.go @@ -46,6 +46,21 @@ type Importer struct { // Used to recompute country / zones from cty.dat so a bad COUNTRY in the // source file (common with contest loggers) is corrected on the way in. Enrich func(*qso.QSO) + // FieldMap moves ADIF fields around before a record is converted, keyed + // lowercase source → lowercase destination. + // + // Contest software puts things where the operator, not the standard, expects + // them. The RSGB IOTA contest is the case that prompted this: N1MM and + // friends export the island reference in STATE, because that is the column + // their contest module uses for the received exchange. Imported verbatim, + // every QSO gets a US-state field holding "EU005" and the IOTA award sees + // nothing at all. + // + // Applied to the RAW record, so it works for promoted columns and Extras + // alike, and so the destination is parsed exactly as if the file had carried + // it there in the first place. A destination that already has a value is + // never overwritten — the file's own IOTA tag outranks a guess about STATE. + FieldMap map[string]string // OnProgress, when set, is called periodically with (processed, total) // record counts so the UI can show a progress bar. total is an estimate // from counting tags up front. @@ -161,6 +176,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult, err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error { res.Total++ reportProgress(false) + applyFieldMap(rec, im.FieldMap) q, ok := recordToQSO(rec) if !ok { res.Skipped++ @@ -303,6 +319,49 @@ func stringSet(items ...string) map[string]struct{} { // RecordToQSO is the exported alias used by the UDP auto-log path so it // can convert a freshly received ADIF record into a QSO and then enrich // it with lookup + operating data before inserting. +// applyFieldMap moves values between tags of a record, in place. +// +// The source is CLEARED after the move: a reference that belongs in IOTA should +// not also be left behind in STATE, where it would show up as the contacted +// station's US state in the grid and in every export. +func applyFieldMap(rec Record, m map[string]string) { + if len(m) == 0 || rec == nil { + return + } + // Read every source first. Applied one at a time, a swap (a→b, b→a) would + // read back what the previous move had just written. + orig := make(map[string]string, len(rec)) + for k, v := range rec { + orig[k] = v + } + vals := make(map[string]string, len(m)) + for from := range m { + if v := strings.TrimSpace(rec[from]); v != "" { + vals[from] = v + } + } + for from := range m { + delete(rec, from) + } + for from, to := range m { + v, ok := vals[from] + if !ok || from == to || to == "" { + continue + } + // Whether the destination was already filled is judged against the record + // as it ARRIVED, not as we are rewriting it — otherwise the outcome would + // depend on Go's map iteration order, which is deliberately random. + // + // A destination that is itself a mapping source is an explicit swap, and + // overwriting it is the whole point; anything else keeps what the file + // carried, because the file's own tag outranks a guess about where a + // reference might have been put. + if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" { + rec[to] = v + } + } +} + func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) } // recordToQSO maps an ADIF record onto a QSO. Returns false if required diff --git a/internal/adif/import_test.go b/internal/adif/import_test.go new file mode 100644 index 0000000..e414ab7 --- /dev/null +++ b/internal/adif/import_test.go @@ -0,0 +1,55 @@ +package adif + +import "testing" + +// Field mapping, from the RSGB IOTA contest case. +// +// N1MM and similar export the island reference in STATE, because that is the +// column their contest module uses for the received exchange. Imported +// verbatim, every QSO gets a US-state field reading "EU005" and the IOTA award +// sees nothing — the reference is in the log but not where anything looks. +func TestApplyFieldMap(t *testing.T) { + // The real record, from an operator's TM2Q log. + rec := Record{ + "call": "2E0CVN", "band": "20m", "mode": "CW", + "state": "EU005", "srx_string": "001EU005", + } + applyFieldMap(rec, map[string]string{"state": "iota"}) + if rec["iota"] != "EU005" { + t.Errorf("iota = %q, want EU005", rec["iota"]) + } + // Cleared, not copied: a reference left behind in STATE would show up as the + // contacted station's US state in the grid and in every export. + if _, ok := rec["state"]; ok { + t.Errorf("state survived the move as %q", rec["state"]) + } + + // A destination the file already filled is never overwritten — the file's own + // tag outranks a guess about where the reference might be. + rec2 := Record{"call": "UT0RM", "state": "EU005", "iota": "EU030"} + applyFieldMap(rec2, map[string]string{"state": "iota"}) + if rec2["iota"] != "EU030" { + t.Errorf("iota = %q — the file's own value must win", rec2["iota"]) + } + + // An empty source moves nothing (the second QSO of that log has no STATE). + rec3 := Record{"call": "UT0RM", "state": " "} + applyFieldMap(rec3, map[string]string{"state": "iota"}) + if v, ok := rec3["iota"]; ok { + t.Errorf("an empty source created iota=%q", v) + } + + // A swap reads both sources before writing either. + rec4 := Record{"state": "EU005", "iota": "NA001"} + applyFieldMap(rec4, map[string]string{"state": "iota", "iota": "state"}) + if rec4["iota"] != "EU005" || rec4["state"] != "NA001" { + t.Errorf("swap gave iota=%q state=%q, want EU005 / NA001", rec4["iota"], rec4["state"]) + } + + // No mapping, no change. + rec5 := Record{"call": "F4BPO", "state": "EU005"} + applyFieldMap(rec5, nil) + if rec5["state"] != "EU005" { + t.Error("a nil mapping must leave the record alone") + } +}