Files
OpsLog/internal/lookup/lookup.go
T
2026-05-28 08:48:41 +02:00

306 lines
9.5 KiB
Go

// 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"`
ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now)
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 (or overrides) country/continent/zones/lat/lon from
// the cty.dat resolver. Default behaviour is "fill empty fields only";
// for slashed callsigns (IT9/DK6XZ, DL/F4NIE…) we OVERRIDE because the
// provider returned the home-call's entity, which is wrong for portable
// operations. The provider keeps Name/QTH/Address (still useful for QSL).
// 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
}
slashed := strings.ContainsRune(r.Callsign, '/')
shouldStr := func(existing string) bool { return existing == "" || slashed }
shouldInt := func(existing int) bool { return existing == 0 || slashed }
shouldF := func(existing float64) bool { return existing == 0 || slashed }
filled := false
if country != "" && shouldStr(r.Country) { r.Country = country; filled = true }
if cont != "" && shouldStr(r.Continent) { r.Continent = cont; filled = true }
if cqz != 0 && shouldInt(r.CQZ) { r.CQZ = cqz; filled = true }
if ituz != 0 && shouldInt(r.ITUZ) { r.ITUZ = ituz; filled = true }
if lat != 0 && shouldF(r.Lat) { r.Lat = lat; filled = true }
if lon != 0 && shouldF(r.Lon) { r.Lon = lon; filled = true }
// QRZ's DXCC number is the home call's — wrong for portable ops.
// cty.dat has no DXCC# (only Clublog does), so clear it: the UI
// will fall back to callsign-level worked-before until we ship a
// proper entity-name → DXCC# mapping.
if slashed && r.DXCC != 0 {
r.DXCC = 0
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, image_url,
source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia, image 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, &image,
&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.ImageURL = image.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, image_url,
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,
image_url = excluded.image_url,
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),
nullable(r.ImageURL),
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
}