fix: ADIF import silently dropped a file with no header
Reported with screenshots: a dozen records lifted out of wsjtx_log.adi into a new .adi imported as "0 imported, 0 ignored, 0 total". The parser only emitted records after <EOH>, and a hand-assembled file has no header — so the whole file was read as header text and thrown away. The count says the file was empty; there is nothing on screen to suggest a missing tag, which is why this was reported as "import does nothing". An <eor> is proof that a record ended, header or no header. Fields are now collected always and DISCARDED at <eoh> — that tag is exactly the statement "everything before this was the header", so real headers stay out of the data just as reliably, and a headerless file keeps its first record instead of losing it to the same rule. An existing test asserted the old behaviour. It encoded a defensible reading of the spec, but the file it rejects is one operators really make and the rejection is silent, so the test is rewritten with the evidence that changed it rather than deleted.
This commit is contained in:
+23
-7
@@ -22,9 +22,11 @@ import (
|
||||
// Record is a single ADIF record. Keys are lowercased field names.
|
||||
type Record map[string]string
|
||||
|
||||
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
||||
// Parse reads an ADI stream and invokes fn for each record.
|
||||
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||
// The header (text before <EOH>) is silently discarded.
|
||||
//
|
||||
// The header (text before <EOH>) is silently discarded. A file with NO header is
|
||||
// read as records from the first tag — hand-assembled files often have none.
|
||||
func Parse(r io.Reader, fn func(Record) error) error {
|
||||
return parseWith(r, nil, fn)
|
||||
}
|
||||
@@ -44,7 +46,6 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
br := bufio.NewReaderSize(r, 64*1024)
|
||||
|
||||
rec := Record{}
|
||||
headerDone := false
|
||||
|
||||
for {
|
||||
// Seek next '<'. Bytes before it are either header text or
|
||||
@@ -65,11 +66,19 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
name, length := parseSpec(spec)
|
||||
switch name {
|
||||
case "eoh":
|
||||
headerDone = true
|
||||
rec = Record{}
|
||||
rec = Record{} // everything collected so far was the header
|
||||
continue
|
||||
case "eor":
|
||||
if headerDone && len(rec) > 0 {
|
||||
// An <eor> proves we are in the record section, whether or not an <eoh>
|
||||
// came first. Files pasted together by hand — a few lines lifted out of
|
||||
// wsjtx_log.adi into a new file — have no header at all, and requiring
|
||||
// one made every record invisible: the import reported 0 imported,
|
||||
// 0 ignored, 0 total, which reads as "the file is empty" rather than
|
||||
// "there was no header".
|
||||
//
|
||||
// A real header cannot contain <eor>, so accepting it costs nothing for
|
||||
// well-formed files: theirs ends at <eoh> long before any record.
|
||||
if len(rec) > 0 {
|
||||
if err := fn(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,7 +104,14 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
|
||||
if decodeValue == nil && !utf8.Valid(val) {
|
||||
val = extendToRunes(br, val, length)
|
||||
}
|
||||
if headerDone && name != "" {
|
||||
// Fields are collected ALWAYS, and thrown away at <eoh>.
|
||||
//
|
||||
// Collecting them only after the header meant a headerless file lost its
|
||||
// FIRST record whole: its fields were discarded as header text, and the
|
||||
// <eor> that followed found nothing to emit. Discarding at <eoh> instead
|
||||
// keeps real headers out of the data just as well — that tag is exactly
|
||||
// what says "everything before this was the header".
|
||||
if name != "" {
|
||||
if decodeValue != nil {
|
||||
rec[name] = decodeValue(val)
|
||||
} else {
|
||||
|
||||
@@ -51,19 +51,28 @@ func TestParseValueWithAngleBracket(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseNoHeader(t *testing.T) {
|
||||
// Some loggers omit the header entirely — records before <EOH> are
|
||||
// discarded by design. Verify nothing is emitted in that case.
|
||||
// A file without a header is IMPORTED, not discarded.
|
||||
//
|
||||
// This test asserted the opposite until 2026-07-29, when an operator pasted a
|
||||
// dozen lines out of wsjtx_log.adi into a new .adi and got "0 imported,
|
||||
// 0 ignored, 0 total". Discarding records before <EOH> was a defensible
|
||||
// reading of the spec, but the file it rejects is one people really make, and
|
||||
// the rejection is silent: the count says the file was empty, not that a tag
|
||||
// was missing. An <eor> is proof enough that a record ended.
|
||||
src := `<CALL:5>F4XYZ<EOR>`
|
||||
var got int
|
||||
var got []Record
|
||||
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||
got++
|
||||
got = append(got, r)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected the record to be imported without <EOH>, got %d", len(got))
|
||||
}
|
||||
if got[0]["call"] != "F4XYZ" {
|
||||
t.Errorf("call = %q, want F4XYZ", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +91,57 @@ func TestParseTypedField(t *testing.T) {
|
||||
t.Errorf("freq mismatch: %q", got["freq"])
|
||||
}
|
||||
}
|
||||
|
||||
// A file with NO header must still import.
|
||||
//
|
||||
// Reported from the field: an operator lifted a dozen lines out of wsjtx_log.adi
|
||||
// into a new .adi and got "0 imported, 0 ignored, 0 total" — which reads as an
|
||||
// empty file rather than as a missing header. Records were only emitted after
|
||||
// <EOH>, so a hand-assembled file was invisible in its entirety.
|
||||
func TestParseHeaderlessFile(t *testing.T) {
|
||||
// The operator's exact shape: records straight from a WSJT-X log, no header.
|
||||
in := "<call:5>PY2VE <gridsquare:4>GG67 <mode:3>FT8 <rst_sent:3>-18 <rst_rcvd:3>-15 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194200 <band:3>15m <eor>\n" +
|
||||
"<call:5>W8OBX <gridsquare:4>FM25 <mode:3>FT8 <rst_sent:3>-19 <rst_rcvd:3>-12 " +
|
||||
"<qso_date:8>20260728 <time_on:6>194500 <band:3>15m <eor>\n"
|
||||
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("parsed %d records from a headerless file, want 2", len(got))
|
||||
}
|
||||
if got[0]["call"] != "PY2VE" || got[1]["call"] != "W8OBX" {
|
||||
t.Errorf("callsigns = %q, %q — want PY2VE, W8OBX", got[0]["call"], got[1]["call"])
|
||||
}
|
||||
if got[0]["gridsquare"] != "GG67" {
|
||||
t.Errorf("gridsquare = %q, want GG67", got[0]["gridsquare"])
|
||||
}
|
||||
}
|
||||
|
||||
// And a normal file must be unaffected: its header is still discarded, so header
|
||||
// fields never leak into the first record.
|
||||
func TestParseHeaderStillDiscarded(t *testing.T) {
|
||||
in := "Generated by SomeLogger\n<adif_ver:5>3.1.4 <programid:6>OpsLog <eoh>\n" +
|
||||
"<call:4>F4AA <mode:2>CW <eor>\n"
|
||||
var got []Record
|
||||
if err := Parse(strings.NewReader(in), func(r Record) error {
|
||||
got = append(got, r)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parsed %d records, want 1", len(got))
|
||||
}
|
||||
if _, leaked := got[0]["programid"]; leaked {
|
||||
t.Error("a header field leaked into the record")
|
||||
}
|
||||
if got[0]["call"] != "F4AA" {
|
||||
t.Errorf("call = %q, want F4AA", got[0]["call"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user