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]
|
||||
}
|
||||
Reference in New Issue
Block a user