chore: release v0.21.3

This commit is contained in:
2026-07-26 16:57:19 +02:00
parent 4fd70f6a9d
commit 91b5af1c7b
46 changed files with 2491 additions and 355 deletions
+41
View File
@@ -19,3 +19,44 @@ func TestHomeCall(t *testing.T) {
}
}
}
// Mobile and maritime-mobile suffixes: /M is the case that surfaced in the field
// (F4LYI/M resolved only to cty.dat while F4LYI resolved on QRZ), so pin the
// whole suffix family — the home call is what the provider record is filed under.
func TestHomeCallSuffixes(t *testing.T) {
for call, want := range map[string]string{
"F4LYI/M": "F4LYI",
"F4LYI/MM": "F4LYI",
"F4LYI/AM": "F4LYI",
"F4LYI/QRP": "F4LYI",
"F4LYI/A": "F4LYI",
"F4LYI/B": "F4LYI",
} {
if got := homeCall(call); got != want {
t.Errorf("homeCall(%q) = %q, want %q", call, got, want)
}
}
}
// Operational suffixes must resolve to the bare call WITHOUT a provider query on
// the slashed form; entity- and area-changing forms must not be stripped.
func TestStripOpSuffix(t *testing.T) {
strip := map[string]string{
"F4LYI/M": "F4LYI", "F4LYI/MM": "F4LYI", "F4LYI/AM": "F4LYI",
"F4LYI/P": "F4LYI", "F4LYI/QRP": "F4LYI", "F4LYI/p": "F4LYI",
"F4BPO/M/P": "F4BPO",
}
for call, want := range strip {
got, ok := stripOpSuffix(call)
if !ok || got != want {
t.Errorf("stripOpSuffix(%q) = %q,%v — want %q,true", call, got, ok, want)
}
}
// These change the entity or the call area: they are real, separately
// registered forms and must be queried exactly as entered.
for _, call := range []string{"JW/OR1A", "VP8/F4BPO", "F4BPO/8", "DL/F4NIE", "OH2BH", "F4BPO/W6"} {
if got, ok := stripOpSuffix(call); ok {
t.Errorf("stripOpSuffix(%q) stripped to %q — it changes entity/area and must be kept", call, got)
}
}
}
+102 -29
View File
@@ -109,22 +109,30 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
}
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
// 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)
}
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
@@ -135,6 +143,10 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
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
@@ -177,6 +189,46 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
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 →
@@ -266,12 +318,30 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
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 }
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
@@ -279,7 +349,10 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// 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 }
if r.DXCC != dxccNum {
r.DXCC = dxccNum
filled = true
}
} else if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
r.DXCC = 0
filled = true
@@ -317,13 +390,13 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
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
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,