183 lines
4.4 KiB
Go
183 lines
4.4 KiB
Go
package dxcc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"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)
|
|
}
|
|
|
|
// EntityNames returns the sorted, de-duplicated DXCC entity names from the
|
|
// loaded cty.dat — the canonical list for a "Country" picker. Empty until
|
|
// cty.dat has loaded.
|
|
func (m *Manager) EntityNames() []string {
|
|
m.mu.RLock()
|
|
db := m.db
|
|
m.mu.RUnlock()
|
|
if db == nil {
|
|
return nil
|
|
}
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, e := range db.Entities() {
|
|
n := strings.TrimSpace(e.Name)
|
|
if n == "" || seen[n] {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
out = append(out, n)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// 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
|
|
}
|