7ace2cc602
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
2.8 KiB
Go
116 lines
2.8 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 {
|
|
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 != "" {
|
|
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)
|
|
}
|
|
}
|