Grid on the UDP path — WSJT-X and MSHV can only send a FOUR-character grid, that is all the FT8 protocol carries. The enrichment rule there is "fill only what is empty", so the coarse JN05 always won and QRZ's JN05JG was thrown away: roughly 100 km of accuracy discarded on every digital QSO. The lookup grid is now taken when it EXTENDS the received square. A lookup that disagrees outright (JN18 against JN05) is not a refinement — the station may be portable and what came over the air is then the better record. Both rules are pinned by tests. Manual fetch in the QSO editor — it read the CACHE, valid for 30 days. An operator who upgraded their QRZ subscription went on getting the thin free-account record and clearing the cached row by hand was the only way out. The button now forces a lookup that bypasses the cache, reaches the provider and REFRESHES the stored row, with a 15 s budget instead of 2 s: a deliberate click must reach the network, where giving up early would fall back to cty.dat and look like nothing happened. The automatic type-ahead lookup keeps the cache, which is where it earns its keep. Same fetch: it used `??`, which only guards against null. Go marshals an unset string as "", so a QRZ record with no grid BLANKED the grid already in the QSO. The lookup still wins — that is the point of asking for it — but an empty result no longer erases a good value.
519 lines
17 KiB
Go
519 lines
17 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"`
|
|
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) (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, not where they
|
|
// are portable now — clear it so cty.dat fills the real entity.
|
|
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 = "", "", ""
|
|
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}
|
|
|
|
// 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.
|
|
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
|
|
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.
|
|
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. 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 {
|
|
updateCols := []string{
|
|
"name", "qth", "address", "state", "cnty",
|
|
"country", "grid", "lat", "lon",
|
|
"dxcc", "cqz", "ituz", "cont", "email", "qsl_via", "image_url",
|
|
"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,
|
|
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),
|
|
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
|
|
}
|