Initial codebase: Go + Wails amateur radio logbook
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>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user