Files
OpsLog/internal/lookup/lookup.go
T
2026-08-18 05:09:04 +02:00

642 lines
22 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"
"math"
"strings"
"sync"
"time"
"unicode"
"hamlog/internal/db"
)
// 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"`
// Web is the operator's own site. The QSO table has had a `web` column all
// along and nothing ever filled it, because no provider mapping read the
// field.
Web string `json:"web,omitempty"`
// IOTA is the island reference (EU-048) for an operator on one. QRZ sends it
// and nothing used to read it — and since no live activation feed exists for
// IOTA the way it does for POTA, the callbook record is the practical source.
IOTA string `json:"iota,omitempty"`
// Zip is the postal code. HamQTH and QRZ both send one.
Zip string `json:"zip,omitempty"`
ImageURL string `json:"image_url,omitempty"` // profile picture URL
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) (dxccNum int, 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.
// forceKey marks a context as a FORCED (operator-requested) lookup, which
// bypasses the cache on the way in and refreshes it on the way out. Carried on
// the context rather than as a parameter so every existing caller — and the
// Provider interface — stays untouched.
type forceKey struct{}
// WithForce returns a context that makes Lookup skip the cache.
func WithForce(ctx context.Context) context.Context {
return context.WithValue(ctx, forceKey{}, true)
}
func isForced(ctx context.Context) bool {
v, _ := ctx.Value(forceKey{}).(bool)
return v
}
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()
// A FORCED lookup skips the cache. The cache is right for the automatic
// lookup that fires as you type, but it also freezes a wrong answer for the
// whole TTL: an operator who upgraded their QRZ subscription kept getting the
// thin free-account record for a month, and clearing the cache by hand was
// the only way out. A lookup the operator asked for by clicking is a
// deliberate act and must reach the provider.
if !isForced(ctx) {
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
// An operational suffix (/M, /P, …) is never registered as such: skip the
// futile query on the slashed form and let the home-call pass below do the one
// request that can actually answer.
_, opOnly := stripOpSuffix(call)
if opOnly {
LogSink("lookup: %s carries only an operational suffix — querying the bare call", call)
} else {
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)
}
}
// Portable / slashed call not found under its full form: the operator's
// record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look
// THAT up for the name/QTH/QSL info, then overwrite the location-determining
// fields with the SLASHED call's entity (JW = Svalbard, not OR1A's Belgium).
if home := homeCall(call); home != "" && home != call {
for _, p := range providers {
r, err := p.Lookup(ctx, home)
if err != nil {
// Logged, because this is where a portable lookup silently dies: the
// error is swallowed to try the next provider, and the operator only
// ever sees the cty.dat fallback with no clue why.
LogSink("lookup: %s → home call %s failed on %s: %v", call, home, p.Name(), err)
continue
}
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
// The home record's location is the operator's HOME — clear it so
// cty.dat fills in where they actually are.
//
// UNLESS the suffix says nothing about location. /QRP is a statement
// about power, not about place: M0BFS/QRP is M0BFS, at home, running
// five watts. Wiping the grid there threw away the one field the
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
_ = m.cache.Put(ctx, r)
return r, nil
}
}
// 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
}
// LogSink receives this package's diagnostic lines (which call was actually
// queried, and why a lookup fell back). Set to applog.Printf by the app.
var LogSink = func(string, ...any) {}
// opSuffixes are OPERATIONAL suffixes: they describe how the operator is working
// — mobile, maritime, aeronautical, portable, low power — not who they are or
// where. No provider has a record filed under "F4LYI/M", so querying that form
// is a round trip that cannot succeed.
//
// It was worse than merely wasted: it spent the lookup's time budget, so the
// home-call retry that followed ran out of time and the entry fell back to
// cty.dat for every /M and /P call, even though the operator was on QRZ. These
// go straight to the bare callsign instead.
//
// Everything else after a slash is NOT this: JW/, VP8/ change the DXCC entity,
// and /8 or /W6 change the call area. Those forms can be registered in their own
// right and must be looked up exactly as entered.
var opSuffixes = map[string]bool{"M": true, "MM": true, "AM": true, "P": true, "QRP": true}
// nonLocationSuffixes say nothing about WHERE the operator is.
//
// /QRP is a statement about power. /M and /P and their kin are not: mobile and
// portable both mean "somewhere other than the home station", which is exactly
// why the home record's location is discarded for them. Keeping that distinction
// is the difference between a grid that is stale and a grid that is absent.
var nonLocationSuffixes = map[string]bool{"QRP": true}
// saysNothingAboutLocation reports a call whose every suffix leaves the operator
// at their registered address — so the home record's location can be trusted.
func saysNothingAboutLocation(call string) bool {
parts := strings.Split(strings.ToUpper(strings.TrimSpace(call)), "/")
if len(parts) < 2 {
return false
}
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return false // "JW/OR1A": the first part is a prefix — a location change
}
for _, p := range parts[1:] {
if !nonLocationSuffixes[strings.TrimSpace(p)] {
return false
}
}
return true
}
// stripOpSuffix returns the bare callsign when call carries nothing but
// operational suffixes ("F4LYI/M" → "F4LYI", true). Reports false for anything
// that changes entity or area ("JW/OR1A", "F4BPO/8"), and for a call whose base
// part isn't callsign-shaped.
func stripOpSuffix(call string) (string, bool) {
if !strings.ContainsRune(call, '/') {
return call, false
}
parts := strings.Split(call, "/")
base := strings.TrimSpace(parts[0])
if len(base) < 3 || !strings.ContainsAny(base, "0123456789") {
return call, false // "JW/OR1A": the first part is a prefix, not the callsign
}
for _, p := range parts[1:] {
if !opSuffixes[strings.ToUpper(strings.TrimSpace(p))] {
return call, false
}
}
return base, true
}
// homeCall extracts the operator's home callsign from a slashed/portable call
// so its provider record (name/QTH/QSL) can be fetched when the full form isn't
// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO →
// F4BPO. The home call is the "/"-part that looks like a real callsign (has a
// digit, ≥3 chars); the longest such part wins (handles PREFIX/HOMECALL where
// both could qualify, e.g. VP8/F4BPO). Returns "" if none qualifies.
func homeCall(call string) string {
if !strings.ContainsRune(call, '/') {
return call
}
best := ""
for _, p := range strings.Split(call, "/") {
p = strings.TrimSpace(p)
if len(p) < 3 || !strings.ContainsAny(p, "0123456789") {
continue // a prefix (JW, DL) or a suffix (P, M, MM, QRP, a digit)
}
if len(p) > len(best) {
best = p
}
}
return best
}
// 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)
// 3 decimals (~110 m) is plenty for a contact's coordinates and keeps
// the displayed/exported value tidy.
r.Lat = round3(r.Lat)
r.Lon = round3(r.Lon)
}
func round3(f float64) float64 { return math.Round(f*1000) / 1000 }
// 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.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
}
dxccNum, country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
if !ok {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r)
filled = true
}
}
}
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
}
// cty.dat is authoritative for the *operating* entity: it strips benign
// suffixes (/P /M /MM /QRP /A …) and honours real prefixes (DL/F4NIE).
// Use its DXCC# when known — this overrides the provider's home-call
// value AND fixes portable calls like F4BPO/P (same entity, must keep
// France's 227). Only when cty.dat can't map a slashed call do we drop
// the provider's number rather than mislabel.
if dxccNum != 0 {
if r.DXCC != dxccNum {
r.DXCC = dxccNum
filled = true
}
} else 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.
//
// A ttl of zero means NO CACHE: every lookup goes to the provider. That is a
// real thing to want — an operator correcting their own QRZ record, or chasing
// a DXpedition whose page changes during the operation, otherwise waits out the
// cache before OpsLog will look again.
type Cache struct {
db *sql.DB
ttl time.Duration
}
// NewCache builds the cache. A ttl of zero here is the CONSTRUCTOR default
// (thirty days), not "off": at startup the settings have not been read yet, and
// starting with no cache would hammer the provider for the first seconds of
// every launch. Switching it off is a decision the operator makes, through
// SetTTL, once their settings are known.
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 lifetime.
//
// ZERO switches the cache OFF — nothing is read from it and nothing is written
// to it. A NEGATIVE value is meaningless and is ignored, rather than being
// rounded into one of the two meanings above.
func (c *Cache) SetTTL(ttl time.Duration) {
if ttl >= 0 {
c.ttl = ttl
}
}
// Enabled reports whether anything is being cached at all.
func (c *Cache) Enabled() bool { return c != nil && c.ttl > 0 }
// Get returns the cached result if present and not expired.
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
if !c.Enabled() {
return Result{}, false
}
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,
web, zip, iota, 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
web, zip, iotaRef 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, &web, &zip, &iotaRef,
&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
}
// A row written before the iota column existed has NULL there, and the cache
// lasts thirty days — so without this every callsign already looked up would
// go a month without its island reference, which is exactly what the first
// test of the feature ran into.
//
// NULL and '' are deliberately different here: Put writes an empty string for
// an operator with no island, so only a row that predates the column reads as
// invalid. One refetch per such callsign, the next time it is used.
if !iotaRef.Valid {
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.Web = web.String
r.Zip = zip.String
r.IOTA = strings.ToUpper(iotaRef.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. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error {
if !c.Enabled() {
// Nothing reads it, so writing would only grow the table — and leave
// stale rows waiting for the day the cache is switched back on.
return nil
}
updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url", "web", "zip", "iota",
"source", "fetched_at",
}
// The lookup cache always lives in the local SQLite database, so SQLite
// upsert syntax is used unconditionally.
sets := make([]string, len(updateCols))
for i, c := range updateCols {
sets[i] = c + " = excluded." + c
}
q := `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, image_url,
web, zip, iota, source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,?,?, ?,?)
ON CONFLICT(callsign) DO UPDATE SET ` + strings.Join(sets, ", ")
_, err := c.db.ExecContext(ctx, q,
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), nullable(r.Web), nullable(r.Zip),
// NOT nullable(): an operator with no island must store '', so that a NULL
// keeps its one meaning — a row written before the column existed.
r.IOTA,
r.Source, db.NowISO(),
)
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
}