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:
@@ -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 <EOR> 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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user