diff --git a/changelog.json b/changelog.json index f19ad47..f189d5f 100644 --- a/changelog.json +++ b/changelog.json @@ -7,14 +7,16 @@ "Web publishing: the page now widens to fit the table. It was capped at a comfortable reading width, so with more than about eight columns the rest sat behind a scrollbar on a screen wide enough to show them all — and Windows hides that scrollbar until something moves, which made a table that scrolls look like a table missing columns. Columns also take the width their contents need instead of being squeezed to fit first.", "Every QSO now carries its distance. Nothing ever recorded one, so the field went out empty in every ADIF export and left the Distance column blank on a published page. It is computed from the two locators when a contact is logged and when an ADIF is imported, and a one-time pass fills in the QSOs already in your log the first time this version runs — in the background, without asking. A distance the imported file supplied is always kept.", "RDA: 1015 districts were filed under the wrong DXCC entity. Every reference sat on European Russia; 991 belong to Asiatic Russia and the 24 KA- districts to Kaliningrad, which is a separate entity altogether. Corrected against the reference list, and Kaliningrad added to the award filter so those 24 can be claimed at all.", - "QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about." + "QSO filter: asking a field to equal nothing now finds the empty ones. SQL answers that question with nothing at all — a missing value never equals an empty one — so the filter looked broken rather than wrong. Empty on a numeric field also covers zero as well as missing, which SQLite and MySQL disagreed about.", + "Callsign lookup: a /QRP call now returns its locator. The lookup falls back to the home callsign when the slashed form is not registered, and then cleared the location — right for /P and /M, where the operator is somewhere other than their registered address, and wrong for /QRP, which says something about power and nothing about place. The grid came back empty while the same call without the suffix answered perfectly." ], "fr": [ "L édition groupée sait enfin régler le mode, le sous-mode et le RST. Ils étaient exclus comme champs propres à chaque QSO, ce qui manquait l essentiel : l édition groupée sert à RÉPARER un lot — un import qui a tout mis en SSB, un ADIF sans aucun mode — et refuser obligeait à corriger cent lignes une par une. Régler le mode efface le sous-mode, celui de l ancien mode contredisant le nouveau. La bande reste avec la fréquence, qui pose déjà les deux ensemble.", "Publication web : la page s élargit désormais à la taille du tableau. Elle était bridée à une largeur de lecture confortable, donc au-delà de huit colonnes environ le reste passait derrière une barre de défilement sur un écran assez large pour tout montrer — et Windows masque cette barre tant que rien ne bouge, si bien qu un tableau qui défile ressemblait à un tableau amputé. Les colonnes prennent aussi la largeur qu il leur faut au lieu d être comprimées d abord.", "Chaque QSO porte désormais sa distance. Rien ne l enregistrait, elle partait donc vide dans chaque export ADIF et laissait la colonne Distance blanche sur une page publiée. Elle est calculée depuis les deux locators à l enregistrement d un contact et à l import d un ADIF, et une passe unique complète les QSO déjà présents au premier lancement de cette version — en tâche de fond, sans rien demander. Une distance fournie par le fichier importé est toujours conservée.", "RDA : 1015 districts étaient rangés sous la mauvaise entité DXCC. Toutes les références étaient sur la Russie européenne ; 991 relèvent de la Russie asiatique et les 24 districts KA- de Kaliningrad, qui est une entité à part entière. Corrigé d après la liste de référence, et Kaliningrad ajouté au filtre de l award pour que ces 24 puissent être revendiqués.", - "Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord." + "Filtre QSO : demander à un champ d être égal à rien trouve désormais les vides. SQL répond à cette question par rien du tout — une valeur absente n est jamais égale à une valeur vide — et le filtre paraissait cassé plutôt que mal posé. Vide sur un champ numérique couvre aussi le zéro autant que l absence, ce sur quoi SQLite et MySQL n étaient pas d accord.", + "Recherche d indicatif : un indicatif en /QRP rend enfin son locator. La recherche se rabat sur l indicatif de base quand la forme avec barre n est pas enregistrée, puis effaçait la localisation — ce qui est juste pour /P et /M, où l opérateur n est pas à son adresse déclarée, et faux pour /QRP, qui parle de puissance et pas de lieu. Le locator revenait vide alors que le même indicatif sans le suffixe répondait parfaitement." ] }, { diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index 487009e..fac3817 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -176,12 +176,20 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { 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 = "", "", "" + // 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) { + 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) @@ -232,6 +240,33 @@ var LogSink = func(string, ...any) {} // 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 diff --git a/internal/lookup/suffix_location_test.go b/internal/lookup/suffix_location_test.go new file mode 100644 index 0000000..eed3337 --- /dev/null +++ b/internal/lookup/suffix_location_test.go @@ -0,0 +1,27 @@ +package lookup + +import "testing" + +// A grid came back empty for M0BFS/QRP while the same lookup without the suffix +// answered perfectly. The home-call pass wiped the location on the grounds that +// a portable operator is not at their registered address — true for /P and /M, +// and simply wrong for /QRP, which is a statement about power. +func TestSaysNothingAboutLocation(t *testing.T) { + keep := []string{"M0BFS/QRP", "f4bpo/qrp", "G0ABC/QRP"} + for _, c := range keep { + if !saysNothingAboutLocation(c) { + t.Errorf("%s: the home location should be kept — /QRP does not move anyone", c) + } + } + // These DO move the operator, or change the entity outright. + drop := []string{"F4BPO/P", "F4BPO/M", "F4BPO/MM", "F4BPO/AM", "JW/OR1A", "VP8/F4BPO", "F4BPO/8", "F4BPO"} + for _, c := range drop { + if saysNothingAboutLocation(c) { + t.Errorf("%s: the home location must NOT be trusted", c) + } + } + // A power suffix on top of a portable one still moves them. + if saysNothingAboutLocation("F4BPO/P/QRP") { + t.Error("F4BPO/P/QRP is portable — location must not be kept") + } +}