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,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