7ace2cc602
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>
157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
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
|
|
}
|