Files
OpsLog/internal/dxcc/manager.go
T
rouggy a554257d5f feat(entry/stats/awards): Alt+W clear, slot drill-down, DXCC prefix column
Five operator-requested items:

- Alt+W clears the QSO entry. Handled before the `typing` guard and above
  the keyer's key routing, so there is always one key that clears whatever
  else is running — Esc is not that key when the CW keyer reserves it.

- The Grid box no longer pops outside the entry panel on a narrow window.
  Row 2 needed 300+130+76+gaps = 538 px inside a panel whose min-width is
  520, so Grid was pushed out and clipped at every narrow width, not just
  extreme ones. QTH's min-width drops to 80 and the row wraps rather than
  overflowing if it ever still can't fit.

- Selecting a QSO in the log now drives the Stats (F1) matrix. Uses its own
  WorkedBefore call into separate state, NOT runWorkedBefore: that one owns
  the entry form's wbRef and can trigger a field backfill, which browsing
  the log must never do. The entry form wins whenever it holds a call.

- Clicking a coloured band/mode square lists the contacts behind it. Returns
  the exact callsign AND the rest of the entity, because that pair is what
  the cell's colour encodes; the call's own QSOs are bolded. The DXCC arm
  matches the stored dxcc column only — reconstructing it from the callsign
  here would disagree with the matrix above, which is built from that column.

- The Awards DXCC list shows each entity's primary prefix in its own sortable
  column. Derived live from cty.dat into Ref.Group rather than stored on the
  reference row, so an existing installation needs no re-seed.
2026-08-05 13:47:36 +02:00

214 lines
5.2 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
}
// PrefixByDXCC maps ADIF DXCC entity number → canonical primary prefix (F, DL,
// XE…) from the loaded cty.dat. cty.dat is keyed by entity NAME, so the join
// goes through EntityDXCC; entries whose name doesn't resolve are skipped
// rather than guessed. Empty until cty.dat has loaded.
func (m *Manager) PrefixByDXCC() map[int]string {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return nil
}
out := make(map[int]string, 400)
for _, e := range db.Entities() {
p := strings.TrimSpace(e.Primary)
if p == "" {
continue
}
// cty.dat marks non-DXCC sub-entities with a leading '*' — they report
// under a parent entity and must not overwrite the parent's prefix.
if strings.HasPrefix(p, "*") {
continue
}
if n := EntityDXCC(e.Name); n > 0 {
if _, dup := out[n]; !dup {
out[n] = p
}
}
}
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
}