135 lines
3.6 KiB
Go
135 lines
3.6 KiB
Go
// 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"
|
|
)
|
|
|
|
// 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>).
|
|
// Returning a non-nil error from fn stops parsing and is propagated.
|
|
// The header (text before <EOH>) is silently discarded.
|
|
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{}
|
|
headerDone := false
|
|
|
|
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":
|
|
headerDone = true
|
|
rec = Record{}
|
|
continue
|
|
case "eor":
|
|
if headerDone && 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)
|
|
}
|
|
if headerDone && name != "" {
|
|
if decodeValue != nil {
|
|
rec[name] = decodeValue(val)
|
|
} else {
|
|
rec[name] = string(val)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|