Files
OpsLog/internal/adif/parser.go
T
rouggy ee1f9ccf35 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.
2026-07-29 15:06:41 +02:00

194 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package adif handles ADIF import and export (ADI text format).
//
// ADI tokenisation rules (per ADIF spec):
// - Free-form text is allowed up to the first <EOH> (header end).
// - After <EOH>, records are sequences of <FIELDNAME:LENGTH[:TYPE]>VALUE
// terminated by <EOR>.
// - The LENGTH is the byte count of the VALUE that immediately follows
// the closing '>' (no separator).
// - Tag names are case-insensitive.
// - Bytes between fields (whitespace, junk) are ignored.
package adif
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
"unicode/utf8"
)
// 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.
// Returning a non-nil error from fn stops parsing and is propagated.
//
// 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)
}
// ParseWithDecoder is like Parse but applies decodeValue to each field's
// raw bytes before storing as a string. ADIF field lengths are byte
// counts in the file's native encoding, so decoding MUST happen after
// reading exactly N bytes — wrapping the reader in a decoder would shift
// byte boundaries and chop multibyte chars in half (e.g. "<QTH:7>YAOUNDÉ"
// in Windows-1252 is 7 bytes; after upfront decoding it'd be 8 bytes of
// UTF-8 and the parser would only read the first 7, splitting É).
func ParseWithDecoder(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
return parseWith(r, decodeValue, fn)
}
func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
br := bufio.NewReaderSize(r, 64*1024)
rec := Record{}
for {
// Seek next '<'. Bytes before it are either header text or
// inter-field whitespace — both discardable.
if err := seekByte(br, '<'); err != nil {
if err == io.EOF {
return nil
}
return err
}
spec, err := readUntilByte(br, '>')
if err != nil {
if err == io.EOF {
return nil
}
return fmt.Errorf("unterminated tag: %w", err)
}
name, length := parseSpec(spec)
switch name {
case "eoh":
rec = Record{} // everything collected so far was the header
continue
case "eor":
// 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
}
}
rec = Record{}
continue
}
// Skip value bytes regardless of header state; we only emit
// records once we've crossed <EOH>.
if length > 0 {
val := make([]byte, length)
if _, err := io.ReadFull(br, val); err != nil {
return fmt.Errorf("read field %s: %w", name, err)
}
// Repair character-count lengths. The ADIF spec says LENGTH is a
// byte count, but some loggers (notably Log4OM's UDP "ADIF
// message") write the CHARACTER count instead. For UTF-8 values
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
// renders as "Tóalm". When we're in UTF-8 mode (no Windows-1252
// decoder) and the naive byte read isn't valid UTF-8, keep reading
// until the value holds `length` whole runes (or the next tag).
if decodeValue == nil && !utf8.Valid(val) {
val = extendToRunes(br, val, length)
}
// 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 {
rec[name] = string(val)
}
}
}
}
}
// extendToRunes recovers a value whose declared length was a character count
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
// append bytes from br until the value holds `wantRunes` complete runes — or
// until the next '<' (start of the following tag) / EOF, so we never cross
// into another field. Capped so a genuinely-corrupt value can't run away.
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
limit := len(have) + maxExtra*wantRunes + maxExtra
for len(have) < limit {
// Stop only when the value is complete UTF-8 (no partial trailing
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
// "а") counts as one rune, so the loop would stop one continuation
// byte short → "Чайк". Requiring utf8.Valid forces us to read it.
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
break
}
b, err := br.ReadByte()
if err != nil {
break
}
if b == '<' {
_ = br.UnreadByte() // belongs to the next tag — leave it
break
}
have = append(have, b)
}
return have
}
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
// name is lowercased; length is 0 for control tags or when missing.
func parseSpec(spec string) (name string, length int) {
parts := strings.SplitN(strings.TrimSpace(spec), ":", 3)
name = strings.ToLower(strings.TrimSpace(parts[0]))
if len(parts) >= 2 {
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && n > 0 {
length = n
}
}
return
}
func seekByte(br *bufio.Reader, target byte) error {
for {
b, err := br.ReadByte()
if err != nil {
return err
}
if b == target {
return nil
}
}
}
func readUntilByte(br *bufio.Reader, target byte) (string, error) {
var sb strings.Builder
for {
b, err := br.ReadByte()
if err != nil {
return sb.String(), err
}
if b == target {
return sb.String(), nil
}
sb.WriteByte(b)
}
}