352 lines
11 KiB
Go
352 lines
11 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"
|
|
"unicode"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
|
|
m.mu.RLock()
|
|
providers := append([]Provider(nil), m.providers...)
|
|
dxcc := m.dxcc
|
|
m.mu.RUnlock()
|
|
|
|
if r, ok := m.cache.Get(ctx, call); ok {
|
|
r.Source = "cache"
|
|
// Re-assert the authoritative DXCC fields (country/zones/continent)
|
|
// from cty.dat on every cache hit — cheap (in-memory) and lets a
|
|
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
|
|
// rows without waiting for the TTL to expire.
|
|
fillFromDXCC(&r, dxcc)
|
|
normalizeNames(&r)
|
|
return r, nil
|
|
}
|
|
|
|
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)
|
|
normalizeNames(&r)
|
|
_ = 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
|
|
}
|
|
|
|
// normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH
|
|
// reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown
|
|
// consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are
|
|
// left untouched (codes like CT must stay as-is).
|
|
func normalizeNames(r *Result) {
|
|
r.Name = titleCase(r.Name)
|
|
r.QTH = titleCase(r.QTH)
|
|
r.Address = titleCase(r.Address)
|
|
}
|
|
|
|
// titleCase lowercases the whole string then capitalises the first letter of
|
|
// each word. Word boundaries are any non-alphanumeric rune (space, hyphen,
|
|
// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and
|
|
// "o'brien" → "O'Brien". Digits never get a leading capital ("74140" stays).
|
|
func titleCase(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
runes := []rune(strings.ToLower(s))
|
|
atWordStart := true
|
|
for i, r := range runes {
|
|
switch {
|
|
case unicode.IsLetter(r):
|
|
if atWordStart {
|
|
runes[i] = unicode.ToUpper(r)
|
|
}
|
|
atWordStart = false
|
|
case unicode.IsDigit(r):
|
|
atWordStart = false
|
|
default:
|
|
atWordStart = true
|
|
}
|
|
}
|
|
return string(runes)
|
|
}
|
|
|
|
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
|
|
// the cty.dat resolver. cty.dat is the authoritative source for DXCC
|
|
// mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it
|
|
// has an answer — QRZ tends to return the political country (Greece for
|
|
// SV5*, Russia for UA9*) instead of the DXCC entity (Dodecanese,
|
|
// Asiatic Russia). Lat/Lon are filled only when empty so a more precise
|
|
// home QTH from QRZ wins over the cty.dat entity centroid.
|
|
//
|
|
// For slashed callsigns (IT9/DK6XZ, DL/F4NIE…) the provider returned the
|
|
// home-call's entity which is wrong for portable operations; we keep the
|
|
// Name/QTH/Address from the provider (still useful for QSL) but reset
|
|
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
|
// → DXCC# table yet.
|
|
// 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
|
|
}
|
|
filled := false
|
|
if country != "" { r.Country = country; filled = true }
|
|
if cont != "" { r.Continent = cont; filled = true }
|
|
if cqz != 0 { r.CQZ = cqz; filled = true }
|
|
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
|
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
|
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
|
// Slashed call → drop QRZ's DXCC# (it's the home call's).
|
|
if strings.ContainsRune(r.Callsign, '/') && 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
|
|
}
|