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,304 @@
|
||||
// Package lookup queries callsign databases (QRZ.com, HamQTH) and caches
|
||||
// results locally so we don't re-hit the network for known calls.
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by providers when a callsign is unknown.
|
||||
var ErrNotFound = errors.New("callsign not found")
|
||||
|
||||
// Result is the normalized lookup output regardless of provider.
|
||||
type Result struct {
|
||||
Callsign string `json:"callsign"`
|
||||
Name string `json:"name,omitempty"`
|
||||
QTH string `json:"qth,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
County string `json:"cnty,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Grid string `json:"grid,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
DXCC int `json:"dxcc,omitempty"`
|
||||
CQZ int `json:"cqz,omitempty"`
|
||||
ITUZ int `json:"ituz,omitempty"`
|
||||
Continent string `json:"cont,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
QSLVia string `json:"qsl_via,omitempty"`
|
||||
Source string `json:"source"` // "qrz", "hamqth", or "cache"
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// Provider is the contract implemented by QRZ, HamQTH, etc.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Lookup(ctx context.Context, callsign string) (Result, error)
|
||||
}
|
||||
|
||||
// DXCCResolver fills the country / zones / continent when the providers
|
||||
// don't (or when no provider returned anything). Decoupled via interface so
|
||||
// `lookup` doesn't import the dxcc package directly.
|
||||
type DXCCResolver interface {
|
||||
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool)
|
||||
}
|
||||
|
||||
// Manager composes a cache with one or more providers.
|
||||
// Lookup tries the cache first, then each enabled provider in order.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
providers []Provider
|
||||
cache *Cache
|
||||
dxcc DXCCResolver
|
||||
}
|
||||
|
||||
func NewManager(cache *Cache) *Manager {
|
||||
return &Manager{cache: cache}
|
||||
}
|
||||
|
||||
// SetDXCCResolver wires the cty.dat-backed fallback that fills country/
|
||||
// zones when the provider chain comes up dry or short.
|
||||
func (m *Manager) SetDXCCResolver(r DXCCResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.dxcc = r
|
||||
}
|
||||
|
||||
// SetProviders replaces the provider chain. Safe to call at any time
|
||||
// (e.g. after the user updates credentials in settings).
|
||||
func (m *Manager) SetProviders(p ...Provider) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers = p
|
||||
}
|
||||
|
||||
// Lookup returns a Result for the callsign. Falls back through providers
|
||||
// when one returns ErrNotFound or fails.
|
||||
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
call := strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if call == "" {
|
||||
return Result{}, fmt.Errorf("empty callsign")
|
||||
}
|
||||
|
||||
if r, ok := m.cache.Get(ctx, call); ok {
|
||||
r.Source = "cache"
|
||||
return r, nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
providers := append([]Provider(nil), m.providers...)
|
||||
dxcc := m.dxcc
|
||||
m.mu.RUnlock()
|
||||
|
||||
var lastErr error
|
||||
for _, p := range providers {
|
||||
r, err := p.Lookup(ctx, call)
|
||||
if err == nil {
|
||||
r.Callsign = call
|
||||
r.Source = p.Name()
|
||||
r.FetchedAt = time.Now().UTC()
|
||||
fillFromDXCC(&r, dxcc)
|
||||
_ = m.cache.Put(ctx, r)
|
||||
return r, nil
|
||||
}
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
|
||||
}
|
||||
|
||||
// All providers exhausted (not-found or errored). Try the cty.dat
|
||||
// resolver as a last resort — at least we can hand back country/zones
|
||||
// even for unknown callsigns. Not cached: a "cty.dat-only" result
|
||||
// shouldn't suppress a later real lookup if the user adds creds.
|
||||
if dxcc != nil {
|
||||
var r Result
|
||||
r.Callsign = call
|
||||
if fillFromDXCC(&r, dxcc) {
|
||||
r.Source = "cty.dat"
|
||||
r.FetchedAt = time.Now().UTC()
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
if len(providers) == 0 {
|
||||
lastErr = fmt.Errorf("no lookup provider configured")
|
||||
} else {
|
||||
lastErr = ErrNotFound
|
||||
}
|
||||
}
|
||||
return Result{}, lastErr
|
||||
}
|
||||
|
||||
// fillFromDXCC fills in country/continent/zones/lat/lon from the cty.dat
|
||||
// resolver when the provider returned them empty. Provider data wins.
|
||||
// Returns true if any field was filled.
|
||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if dxcc == nil {
|
||||
return false
|
||||
}
|
||||
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
filled := false
|
||||
if r.Country == "" && country != "" {
|
||||
r.Country = country
|
||||
filled = true
|
||||
}
|
||||
if r.Continent == "" && cont != "" {
|
||||
r.Continent = cont
|
||||
filled = true
|
||||
}
|
||||
if r.CQZ == 0 && cqz != 0 {
|
||||
r.CQZ = cqz
|
||||
filled = true
|
||||
}
|
||||
if r.ITUZ == 0 && ituz != 0 {
|
||||
r.ITUZ = ituz
|
||||
filled = true
|
||||
}
|
||||
if r.Lat == 0 && lat != 0 {
|
||||
r.Lat = lat
|
||||
filled = true
|
||||
}
|
||||
if r.Lon == 0 && lon != 0 {
|
||||
r.Lon = lon
|
||||
filled = true
|
||||
}
|
||||
return filled
|
||||
}
|
||||
|
||||
// ----- Cache -----
|
||||
|
||||
// Cache is a SQLite-backed cache of lookup results with a TTL.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
return &Cache{db: db, ttl: ttl}
|
||||
}
|
||||
|
||||
// SetTTL updates the cache TTL (e.g. when user changes settings).
|
||||
func (c *Cache) SetTTL(ttl time.Duration) {
|
||||
if ttl > 0 {
|
||||
c.ttl = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the cached result if present and not expired.
|
||||
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via,
|
||||
source, fetched_at
|
||||
FROM callsign_cache WHERE callsign = ?`, callsign)
|
||||
var (
|
||||
r Result
|
||||
name, qth, addr, state, cnty sql.NullString
|
||||
country, grid, cont, email, qslVia sql.NullString
|
||||
src string
|
||||
dxcc, cqz, ituz sql.NullInt64
|
||||
lat, lon sql.NullFloat64
|
||||
fetched string
|
||||
)
|
||||
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
|
||||
&country, &grid, &lat, &lon,
|
||||
&dxcc, &cqz, &ituz, &cont, &email, &qslVia,
|
||||
&src, &fetched); err != nil {
|
||||
return Result{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02T15:04:05.000Z", fetched)
|
||||
if err != nil {
|
||||
t, _ = time.Parse(time.RFC3339, fetched)
|
||||
}
|
||||
if time.Since(t) > c.ttl {
|
||||
return Result{}, false
|
||||
}
|
||||
r.Name = name.String
|
||||
r.QTH = qth.String
|
||||
r.Address = addr.String
|
||||
r.State = state.String
|
||||
r.County = cnty.String
|
||||
r.Country = country.String
|
||||
r.Grid = grid.String
|
||||
r.Lat = lat.Float64
|
||||
r.Lon = lon.Float64
|
||||
r.Continent = cont.String
|
||||
r.Email = email.String
|
||||
r.QSLVia = qslVia.String
|
||||
r.DXCC = int(dxcc.Int64)
|
||||
r.CQZ = int(cqz.Int64)
|
||||
r.ITUZ = int(ituz.Int64)
|
||||
r.Source = src
|
||||
r.FetchedAt = t
|
||||
return r, true
|
||||
}
|
||||
|
||||
// Put upserts a lookup result.
|
||||
func (c *Cache) Put(ctx context.Context, r Result) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
|
||||
country, grid, lat, lon,
|
||||
dxcc, cqz, ituz, cont, email, qsl_via,
|
||||
source, fetched_at)
|
||||
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?, ?,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(callsign) DO UPDATE SET
|
||||
name = excluded.name, qth = excluded.qth, address = excluded.address,
|
||||
state = excluded.state, cnty = excluded.cnty,
|
||||
country = excluded.country, grid = excluded.grid,
|
||||
lat = excluded.lat, lon = excluded.lon,
|
||||
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
|
||||
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
|
||||
source = excluded.source, fetched_at = excluded.fetched_at`,
|
||||
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
|
||||
nullable(r.State), nullable(r.County),
|
||||
nullable(r.Country), nullable(r.Grid),
|
||||
nullableFloat(r.Lat), nullableFloat(r.Lon),
|
||||
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
|
||||
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
|
||||
r.Source,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableFloat(f float64) any {
|
||||
if f == 0 {
|
||||
return nil
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Clear empties the cache. Useful for "Refresh cache" admin actions.
|
||||
func (c *Cache) Clear(ctx context.Context) error {
|
||||
_, err := c.db.ExecContext(ctx, `DELETE FROM callsign_cache`)
|
||||
return err
|
||||
}
|
||||
|
||||
func nullable(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
func nullableInt(n int) any {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user