// 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 (header end). // - After , records are sequences of VALUE // terminated by . // - 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 ). // Returning a non-nil error from fn stops parsing and is propagated. // The header (text before ) is silently discarded. func Parse(r io.Reader, 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 . 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 != "" { 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) } }