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:
2026-05-26 00:16:45 +02:00
parent 734d296300
commit 7ace2cc602
87 changed files with 15892 additions and 0 deletions
+293
View File
@@ -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]
}
+74
View File
@@ -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)
}
}
}
+156
View File
@@ -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
}