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,293 @@
|
||||
// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU
|
||||
// zones, continent) by longest-prefix-matching against cty.dat — the
|
||||
// canonical prefix database maintained by AD1C at country-files.com,
|
||||
// the same file that every contest / logger consumes.
|
||||
//
|
||||
// The parser is line-oriented and tolerant: it handles cty.dat's
|
||||
// per-prefix overrides ((CQ), [ITU], <lat/lon>, {Cont}) and the
|
||||
// "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM,
|
||||
// /5, …) are stripped before matching.
|
||||
package dxcc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Entity is one DXCC entity entry from cty.dat.
|
||||
type Entity struct {
|
||||
Name string `json:"name"`
|
||||
Continent string `json:"continent"`
|
||||
CQZone int `json:"cqz"`
|
||||
ITUZone int `json:"ituz"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
TZOffset float64 `json:"tz_offset"`
|
||||
Primary string `json:"primary_prefix"` // canonical short prefix (F, DL, K, …)
|
||||
}
|
||||
|
||||
// Match is the resolved DXCC info for a callsign. Per-prefix overrides
|
||||
// from cty.dat are baked in; the Entity pointer is the unmodified parent.
|
||||
type Match struct {
|
||||
Entity *Entity `json:"entity"`
|
||||
Prefix string `json:"matched_prefix"`
|
||||
CQZone int `json:"cqz"`
|
||||
ITUZone int `json:"ituz"`
|
||||
Continent string `json:"continent"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
type prefixEntry struct {
|
||||
prefix string
|
||||
entity *Entity
|
||||
cqOverride int
|
||||
ituOverride int
|
||||
contOverride string
|
||||
latOverride float64
|
||||
lonOverride float64
|
||||
hasLatLon bool
|
||||
}
|
||||
|
||||
// DB is a parsed cty.dat ready for lookups.
|
||||
type DB struct {
|
||||
mu sync.RWMutex
|
||||
entities []*Entity
|
||||
exact map[string]prefixEntry // "=CALLSIGN" entries
|
||||
byPrefix []prefixEntry // sorted longest first
|
||||
}
|
||||
|
||||
// Load parses a cty.dat stream. Safe to call once at startup.
|
||||
func Load(r io.Reader) (*DB, error) {
|
||||
db := &DB{exact: make(map[string]prefixEntry)}
|
||||
sc := bufio.NewScanner(r)
|
||||
// cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine
|
||||
// but we bump it to be safe.
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
||||
var current *Entity
|
||||
var buf strings.Builder
|
||||
|
||||
for sc.Scan() {
|
||||
line := strings.TrimRight(sc.Text(), "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Entity header lines start at column 0; continuation lines are
|
||||
// indented (cty.dat uses 4 spaces).
|
||||
if line[0] != ' ' && line[0] != '\t' {
|
||||
if e := parseEntityHeader(line); e != nil {
|
||||
db.entities = append(db.entities, e)
|
||||
current = e
|
||||
buf.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
buf.WriteString(strings.TrimSpace(line))
|
||||
// An entity's prefix list ends with ';' — possibly on a later line.
|
||||
if strings.HasSuffix(strings.TrimSpace(line), ";") {
|
||||
text := strings.TrimSuffix(buf.String(), ";")
|
||||
for _, raw := range strings.Split(text, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
entry, exact := parsePrefix(raw, current)
|
||||
if exact {
|
||||
db.exact[entry.prefix] = entry
|
||||
} else {
|
||||
db.byPrefix = append(db.byPrefix, entry)
|
||||
}
|
||||
}
|
||||
buf.Reset()
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Longest prefix first so HasPrefix wins on the most specific match.
|
||||
sort.Slice(db.byPrefix, func(i, j int) bool {
|
||||
return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix)
|
||||
})
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Entities returns the parsed entity list (read-only).
|
||||
func (db *DB) Entities() []*Entity {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
return db.entities
|
||||
}
|
||||
|
||||
// Lookup resolves a callsign to its DXCC match using longest-prefix-match.
|
||||
// Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes
|
||||
// (DL/F4NIE → uses DL). Returns false if no prefix matches.
|
||||
func (db *DB) Lookup(callsign string) (Match, bool) {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
call := normalizeCallsign(callsign)
|
||||
if call == "" {
|
||||
return Match{}, false
|
||||
}
|
||||
if e, ok := db.exact[call]; ok {
|
||||
return materialize(e), true
|
||||
}
|
||||
for _, p := range db.byPrefix {
|
||||
if strings.HasPrefix(call, p.prefix) {
|
||||
return materialize(p), true
|
||||
}
|
||||
}
|
||||
return Match{}, false
|
||||
}
|
||||
|
||||
func materialize(e prefixEntry) Match {
|
||||
m := Match{
|
||||
Entity: e.entity,
|
||||
Prefix: e.prefix,
|
||||
CQZone: e.entity.CQZone,
|
||||
ITUZone: e.entity.ITUZone,
|
||||
Continent: e.entity.Continent,
|
||||
Lat: e.entity.Lat,
|
||||
Lon: e.entity.Lon,
|
||||
}
|
||||
if e.cqOverride != 0 {
|
||||
m.CQZone = e.cqOverride
|
||||
}
|
||||
if e.ituOverride != 0 {
|
||||
m.ITUZone = e.ituOverride
|
||||
}
|
||||
if e.contOverride != "" {
|
||||
m.Continent = e.contOverride
|
||||
}
|
||||
if e.hasLatLon {
|
||||
m.Lat = e.latOverride
|
||||
m.Lon = e.lonOverride
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// parseEntityHeader parses the colon-separated entity line:
|
||||
//
|
||||
// "France: 14: 27: EU: 46.00: -2.00: -1.0: F:"
|
||||
func parseEntityHeader(line string) *Entity {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 8 {
|
||||
return nil
|
||||
}
|
||||
e := &Entity{
|
||||
Name: strings.TrimSpace(parts[0]),
|
||||
Continent: strings.TrimSpace(parts[3]),
|
||||
Primary: strings.TrimSpace(parts[7]),
|
||||
}
|
||||
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||
e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
|
||||
e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64)
|
||||
e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
|
||||
if e.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// parsePrefix peels off cty.dat per-prefix annotations:
|
||||
//
|
||||
// K (5)[7]<35.50/-95.00>{NA}~America/Chicago~
|
||||
// =W1AW
|
||||
func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
|
||||
out := prefixEntry{entity: e}
|
||||
exact := false
|
||||
if strings.HasPrefix(s, "=") {
|
||||
exact = true
|
||||
s = s[1:]
|
||||
}
|
||||
// Strip annotations. Order them roughly so we extract before they appear
|
||||
// in the prefix slice.
|
||||
s = stripAnnotation(s, '(', ')', func(v string) {
|
||||
out.cqOverride, _ = strconv.Atoi(v)
|
||||
})
|
||||
s = stripAnnotation(s, '[', ']', func(v string) {
|
||||
out.ituOverride, _ = strconv.Atoi(v)
|
||||
})
|
||||
s = stripAnnotation(s, '<', '>', func(v string) {
|
||||
if a, b, ok := strings.Cut(v, "/"); ok {
|
||||
lat, e1 := strconv.ParseFloat(a, 64)
|
||||
lon, e2 := strconv.ParseFloat(b, 64)
|
||||
if e1 == nil && e2 == nil {
|
||||
out.latOverride, out.lonOverride = lat, lon
|
||||
out.hasLatLon = true
|
||||
}
|
||||
}
|
||||
})
|
||||
s = stripAnnotation(s, '{', '}', func(v string) {
|
||||
out.contOverride = strings.TrimSpace(v)
|
||||
})
|
||||
s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ })
|
||||
out.prefix = strings.ToUpper(strings.TrimSpace(s))
|
||||
return out, exact
|
||||
}
|
||||
|
||||
// stripAnnotation removes a single ...X...Y... block and invokes cb with the
|
||||
// inner text. Used for (CQ), [ITU], <lat/lon>, {cont}, ~tz~ annotations.
|
||||
func stripAnnotation(s string, open, close rune, cb func(string)) string {
|
||||
i := strings.IndexRune(s, open)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
j := strings.IndexRune(s[i+1:], close)
|
||||
if j < 0 {
|
||||
return s
|
||||
}
|
||||
cb(s[i+1 : i+1+j])
|
||||
return s[:i] + s[i+1+j+1:]
|
||||
}
|
||||
|
||||
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
||||
// matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all
|
||||
// keep the operator's home DXCC.
|
||||
var suffixModifiers = map[string]bool{
|
||||
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
|
||||
"PM": true, "LH": true,
|
||||
}
|
||||
|
||||
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
||||
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE).
|
||||
func normalizeCallsign(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if !strings.ContainsRune(s, '/') {
|
||||
return s
|
||||
}
|
||||
parts := strings.Split(s, "/")
|
||||
keep := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if suffixModifiers[p] {
|
||||
continue
|
||||
}
|
||||
if len(p) == 1 && p >= "0" && p <= "9" {
|
||||
continue
|
||||
}
|
||||
keep = append(keep, p)
|
||||
}
|
||||
switch len(keep) {
|
||||
case 0:
|
||||
return s
|
||||
case 1:
|
||||
return keep[0]
|
||||
}
|
||||
// Two non-modifier parts → operating-from prefix wins (shorter one).
|
||||
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
|
||||
if len(keep[0]) <= len(keep[1]) {
|
||||
return keep[0]
|
||||
}
|
||||
return keep[1]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dxcc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleCty = `Sov Mil Order of Malta: 15: 28: EU: 41.90: -12.43: -1.0: 1A:
|
||||
1A;
|
||||
Monaco: 14: 27: EU: 43.73: -7.40: -1.0: 3A:
|
||||
3A;
|
||||
France: 14: 27: EU: 46.00: -2.00: -1.0: F:
|
||||
F,HW,HX,HY,TH,TM,TO,TP,TQ,TV,TX;
|
||||
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||
DA,DB,DC,DD,DE,DF,DG,DH,DI,DJ,DK,DL,DM,DN,DO,DP,DQ,DR;
|
||||
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
|
||||
=W1AW(5)[7],K,N,W,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK;
|
||||
`
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
db, err := Load(strings.NewReader(sampleCty))
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
call string
|
||||
wantEnt string
|
||||
}{
|
||||
{"F4NIE", "France"},
|
||||
{"F4BPO", "France"},
|
||||
{"F4BPO/P", "France"},
|
||||
{"DL/F4NIE", "Germany"},
|
||||
{"DL5XYZ", "Germany"},
|
||||
{"K1ABC", "United States"},
|
||||
{"N0CALL", "United States"},
|
||||
{"3A2MD", "Monaco"},
|
||||
{"W1AW", "United States"}, // exact match wins
|
||||
}
|
||||
for _, c := range cases {
|
||||
m, ok := db.Lookup(c.call)
|
||||
if !ok {
|
||||
t.Errorf("%s: no match", c.call)
|
||||
continue
|
||||
}
|
||||
if m.Entity.Name != c.wantEnt {
|
||||
t.Errorf("%s: got %q, want %q", c.call, m.Entity.Name, c.wantEnt)
|
||||
}
|
||||
}
|
||||
|
||||
// W1AW exact match has CQ override 5 and ITU override 7.
|
||||
m, _ := db.Lookup("W1AW")
|
||||
if m.CQZone != 5 || m.ITUZone != 7 {
|
||||
t.Errorf("W1AW overrides: got CQ=%d ITU=%d, want 5/7", m.CQZone, m.ITUZone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"F4BPO": "F4BPO",
|
||||
"f4bpo": "F4BPO",
|
||||
" F4BPO ": "F4BPO",
|
||||
"F4BPO/P": "F4BPO",
|
||||
"F4BPO/MM": "F4BPO",
|
||||
"F4BPO/5": "F4BPO",
|
||||
"DL/F4BPO": "DL",
|
||||
"F4BPO/W6": "W6",
|
||||
"VK9/F4BPO": "VK9",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeCallsign(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package dxcc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CtyDatURL is the canonical source of cty.dat. AD1C ships updates roughly
|
||||
// monthly; we cache the file on disk so we don't hammer it.
|
||||
const CtyDatURL = "https://www.country-files.com/cty/cty.dat"
|
||||
|
||||
// Manager owns the on-disk cty.dat cache and the parsed DB. Safe for
|
||||
// concurrent reads after Load; concurrent reloads serialize on its lock.
|
||||
type Manager struct {
|
||||
cacheDir string
|
||||
|
||||
mu sync.RWMutex
|
||||
db *DB
|
||||
src ctySource // metadata about whichever copy we loaded
|
||||
|
||||
loading atomic.Bool
|
||||
}
|
||||
|
||||
type ctySource struct {
|
||||
Path string `json:"path"`
|
||||
LoadedAt time.Time `json:"loaded_at"`
|
||||
FileModTime time.Time `json:"file_mod_time"`
|
||||
Entities int `json:"entities"`
|
||||
Downloaded bool `json:"downloaded"`
|
||||
}
|
||||
|
||||
// NewManager prepares a manager rooted at cacheDir (created if missing).
|
||||
// Does not load anything — call EnsureLoaded after.
|
||||
func NewManager(cacheDir string) *Manager {
|
||||
return &Manager{cacheDir: cacheDir}
|
||||
}
|
||||
|
||||
// Path returns the on-disk path where cty.dat is/should be cached.
|
||||
func (m *Manager) Path() string {
|
||||
return filepath.Join(m.cacheDir, "cty.dat")
|
||||
}
|
||||
|
||||
// EnsureLoaded loads cty.dat from disk; if missing, downloads it first.
|
||||
// Safe to call repeatedly — only the first run actually downloads.
|
||||
func (m *Manager) EnsureLoaded(ctx context.Context) error {
|
||||
if _, err := os.Stat(m.Path()); os.IsNotExist(err) {
|
||||
if err := m.Download(ctx); err != nil {
|
||||
return fmt.Errorf("download cty.dat: %w", err)
|
||||
}
|
||||
}
|
||||
return m.LoadFromDisk()
|
||||
}
|
||||
|
||||
// LoadFromDisk parses the cached cty.dat into a fresh DB and swaps it in.
|
||||
func (m *Manager) LoadFromDisk() error {
|
||||
f, err := os.Open(m.Path())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
db, err := Load(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.db = db
|
||||
m.src = ctySource{
|
||||
Path: m.Path(),
|
||||
LoadedAt: time.Now(),
|
||||
FileModTime: info.ModTime(),
|
||||
Entities: len(db.entities),
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download fetches a fresh cty.dat from CtyDatURL and atomically replaces
|
||||
// the on-disk cache. Does NOT reload it into memory — caller can chain
|
||||
// LoadFromDisk for that.
|
||||
func (m *Manager) Download(ctx context.Context) error {
|
||||
if !m.loading.CompareAndSwap(false, true) {
|
||||
return fmt.Errorf("cty.dat download already in progress")
|
||||
}
|
||||
defer m.loading.Store(false)
|
||||
|
||||
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", CtyDatURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// Write to a temp file in the same dir, then atomic rename — avoids a
|
||||
// half-written file if we crash mid-download.
|
||||
tmp, err := os.CreateTemp(m.cacheDir, "cty-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
_, err = io.Copy(tmp, resp.Body)
|
||||
tmp.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.Path()); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresh = Download + LoadFromDisk in one call.
|
||||
func (m *Manager) Refresh(ctx context.Context) error {
|
||||
if err := m.Download(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.LoadFromDisk()
|
||||
}
|
||||
|
||||
// Lookup is a passthrough to the loaded DB. Returns false if no DB is
|
||||
// loaded yet (callers should treat that as graceful degradation).
|
||||
func (m *Manager) Lookup(callsign string) (Match, bool) {
|
||||
m.mu.RLock()
|
||||
db := m.db
|
||||
m.mu.RUnlock()
|
||||
if db == nil {
|
||||
return Match{}, false
|
||||
}
|
||||
return db.Lookup(callsign)
|
||||
}
|
||||
|
||||
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||
// if nothing loaded).
|
||||
func (m *Manager) Info() ctySource {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.src
|
||||
}
|
||||
Reference in New Issue
Block a user