From c9e98d5fdddb85e354d069b3a0eeb212c6a18da4 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 29 Aug 2026 01:25:34 +0200 Subject: [PATCH] feat(watchlist): exact match by default, * for a family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix matching ported from DXHunter swallowed too much: a three-letter special-event entry N8W lit up NEEDED for N8WCR, a different station entirely. The implicit becomes explicit — a bare entry matches exactly that call, a trailing star makes it a family: VK9* catches every VK9…, RI0SP* the expedition's portable forms. A star anywhere else (or alone) is refused at Add rather than silently matching nothing. Same rule in the backend Match and the tab's own matcher, pinned by test. --- changelog.json | 4 ++-- frontend/src/components/WatchlistTab.tsx | 9 ++++++- frontend/src/lib/i18n.tsx | 8 +++---- internal/watchlist/watchlist.go | 30 ++++++++++++++++++++---- internal/watchlist/watchlist_test.go | 28 ++++++++++++++++------ 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/changelog.json b/changelog.json index 86705cb..cf259ab 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,7 @@ "DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty.", "Elecraft console: RIT and XIT use the same control as the Icom and TCI consoles — ± buttons, mouse wheel, typed value, Ctrl+←/→ — and the power slider moves in 1 W steps instead of 5.", "Bulk edit: My DXCC, My CQ zone and My ITU zone join the My-station fields — numbers checked against their real ranges, empty clears.", - "Watchlist (Tools): the DXHunter watchlist as an OpsLog tab — callsigns or prefixes you are hunting, the live cluster spots under each with the NEW badges and a Needed/Worked verdict per slot, an ON AIR badge, per-entry alerts through the normal alert path, and CONTEST entries judged per UTC day (at midnight everything is workable again). Reads and writes DXHunter’s own watchlist.json format — drop your existing file into the data folder to carry it over." + "Watchlist (Tools): the DXHunter watchlist as an OpsLog tab — callsigns you are hunting (exact, or a family with a trailing * — VK9* catches every VK9), the live cluster spots under each with the NEW badges and a Needed/Worked verdict per slot, an ON AIR badge, per-entry alerts through the normal alert path, and CONTEST entries judged per UTC day (at midnight everything is workable again). Reads and writes DXHunter’s own watchlist.json format — drop your existing file into the data folder to carry it over." ], "fr": [ "TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».", @@ -20,7 +20,7 @@ "Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides.", "Console Elecraft : le RIT et le XIT utilisent la même commande que les consoles Icom et TCI — boutons ±, molette, valeur tapée, Ctrl+←/→ — et le curseur de puissance avance par pas de 1 W au lieu de 5.", "Édition groupée : My DXCC, My CQ zone et My ITU zone rejoignent les champs Ma station — valeurs vérifiées contre leurs bornes réelles, vide efface.", - "Watchlist (Outils) : la watchlist de DXHunter en onglet OpsLog — indicatifs ou préfixes chassés, les spots cluster en direct sous chaque entrée avec les badges NOUVEAU et un verdict Manquant/Contacté par créneau, un badge ON AIR, des alertes par entrée via le circuit d'alerte normal, et des entrées CONTEST jugées par jour UTC (à minuit tout redevient à faire). Lit et écrit le format watchlist.json de DXHunter — déposez votre fichier existant dans le dossier data pour le récupérer." + "Watchlist (Outils) : la watchlist de DXHunter en onglet OpsLog — indicatifs chassés (exacts, ou une famille avec une * finale — VK9* attrape tous les VK9), les spots cluster en direct sous chaque entrée avec les badges NOUVEAU et un verdict Manquant/Contacté par créneau, un badge ON AIR, des alertes par entrée via le circuit d'alerte normal, et des entrées CONTEST jugées par jour UTC (à minuit tout redevient à faire). Lit et écrit le format watchlist.json de DXHunter — déposez votre fichier existant dans le dossier data pour le récupérer." ] }, { diff --git a/frontend/src/components/WatchlistTab.tsx b/frontend/src/components/WatchlistTab.tsx index d31e5fb..b8259e5 100644 --- a/frontend/src/components/WatchlistTab.tsx +++ b/frontend/src/components/WatchlistTab.tsx @@ -43,9 +43,16 @@ interface Props { // A spot is ON AIR for the badge while its last sighting is this fresh. const ON_AIR_MS = 10 * 60 * 1000; +// Exact unless the entry carries a trailing * — the same rule the backend's +// Match applies to the live stream, mirrored so the tab and the alerts can +// never disagree about what an entry covers. function matchesEntry(call: string, pattern: string): boolean { const c = call.toUpperCase(); - return c === pattern || c.startsWith(pattern); + if (pattern.endsWith('*')) { + const p = pattern.slice(0, -1); + return p !== '' && c.startsWith(p); + } + return c === pattern; } export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: Props) { diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index b1398d9..6ceaa81 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -39,11 +39,11 @@ const en: Dict = { 'cwd.tipOnIdle': 'CW decoder — on, idle until CW mode · click to disable', 'cwd.tipOff': 'CW decoder · click to enable (decodes RX audio in CW mode)', 'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist', - 'wl.addPh': 'Callsign or prefix', 'wl.add': 'Add', 'wl.contest': 'Contest', 'wl.addAsContest': 'as contest', 'wl.cTotal': 'Watchlist:', 'wl.cActive': 'Active:', 'wl.cNeeded': 'Needed:', 'wl.allModes': 'All modes', 'wl.modeFilter': 'Only show spots in this mode', 'wl.patternPh': 'Auto contest (e.g. WWA)', 'wl.patternHint': 'Auto-add as contest: any spotted callsign CONTAINING this text joins the watchlist as a contest entry by itself. Empty = off; collected entries stay.', + 'wl.addPh': 'Callsign or VK9*', 'wl.add': 'Add', 'wl.contest': 'Contest', 'wl.addAsContest': 'as contest', 'wl.cTotal': 'Watchlist:', 'wl.cActive': 'Active:', 'wl.cNeeded': 'Needed:', 'wl.allModes': 'All modes', 'wl.modeFilter': 'Only show spots in this mode', 'wl.patternPh': 'Auto contest (e.g. WWA)', 'wl.patternHint': 'Auto-add as contest: any spotted callsign CONTAINING this text joins the watchlist as a contest entry by itself. Empty = off; collected entries stay.', 'wl.contestHint': 'Contest station: worked/needed is judged against the current UTC day — at 00:00 UTC every slot can be worked again.', 'wl.searchPh': 'Search…', 'wl.famAll': 'All', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest', 'wl.activeOnly': 'Active', 'wl.neededOnly': 'Needed', - 'wl.empty': 'Add the callsigns or prefixes you are hunting — a prefix catches the portable forms too (RI0SP matches RI0SP/MM). Spots from the cluster appear under each entry with what they are worth.', + 'wl.empty': 'Add the callsigns you are hunting. A bare entry matches exactly that call; end it with * for a family — VK9* catches every VK9…, RI0SP* the expedition\u2019s portable forms. Spots from the cluster appear under each entry with what they are worth.', 'wl.noneMatch': 'Nothing matches the current filters.', 'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpedition', 'wl.nNeeded': '{n} needed', 'wl.nToday': '{n} today', 'wl.allWorked': 'All worked', 'wl.workedToday': 'Worked today', @@ -563,11 +563,11 @@ const fr: Dict = { 'cwd.tipOnIdle': 'Décodeur CW — actif, en veille hors mode CW · clic pour désactiver', 'cwd.tipOff': 'Décodeur CW · clic pour activer (décode l’audio RX en mode CW)', 'tools.watchlist': 'Watchlist…', 'tab.watchlist': 'Watchlist', - 'wl.addPh': 'Indicatif ou préfixe', 'wl.add': 'Ajouter', 'wl.contest': 'Contest', 'wl.addAsContest': 'comme contest', 'wl.cTotal': 'Watchlist :', 'wl.cActive': 'Actives :', 'wl.cNeeded': 'Manquantes :', 'wl.allModes': 'Tous les modes', 'wl.modeFilter': 'Ne montrer que les spots de ce mode', 'wl.patternPh': 'Auto contest (ex. WWA)', 'wl.patternHint': "Ajout auto comme contest : tout indicatif spotté CONTENANT ce texte rejoint la watchlist en entrée contest tout seul. Vide = désactivé ; les entrées déjà collectées restent.", + 'wl.addPh': 'Indicatif ou VK9*', 'wl.add': 'Ajouter', 'wl.contest': 'Contest', 'wl.addAsContest': 'comme contest', 'wl.cTotal': 'Watchlist :', 'wl.cActive': 'Actives :', 'wl.cNeeded': 'Manquantes :', 'wl.allModes': 'Tous les modes', 'wl.modeFilter': 'Ne montrer que les spots de ce mode', 'wl.patternPh': 'Auto contest (ex. WWA)', 'wl.patternHint': "Ajout auto comme contest : tout indicatif spotté CONTENANT ce texte rejoint la watchlist en entrée contest tout seul. Vide = désactivé ; les entrées déjà collectées restent.", 'wl.contestHint': "Station contest : contacté/manquant est jugé sur la journée UTC courante — à 00:00 UTC chaque créneau redevient à faire.", 'wl.searchPh': 'Chercher…', 'wl.famAll': 'Tous', 'wl.famNormal': 'DX', 'wl.famContest': 'Contest', 'wl.activeOnly': 'Actifs', 'wl.neededOnly': 'Manquants', - 'wl.empty': "Ajoutez les indicatifs ou préfixes que vous chassez — un préfixe attrape aussi les formes portables (RI0SP matche RI0SP/MM). Les spots du cluster apparaissent sous chaque entrée avec ce qu'ils valent.", + 'wl.empty': "Ajoutez les indicatifs que vous chassez. Une entrée nue matche exactement ce call ; terminez par * pour une famille — VK9* attrape tous les VK9…, RI0SP* les formes portables de l'expédition. Les spots du cluster apparaissent sous chaque entrée avec ce qu'ils valent.", 'wl.noneMatch': 'Rien ne correspond aux filtres actuels.', 'wl.onAir': 'ON AIR', 'wl.expedition': 'DXpédition', 'wl.nNeeded': '{n} manquants', 'wl.nToday': "{n} aujourd'hui", 'wl.allWorked': 'Tout contacté', 'wl.workedToday': "Contacté aujourd'hui", diff --git a/internal/watchlist/watchlist.go b/internal/watchlist/watchlist.go index ef7369c..adfef5c 100644 --- a/internal/watchlist/watchlist.go +++ b/internal/watchlist/watchlist.go @@ -99,12 +99,20 @@ func (s *Store) Entries() []Entry { return out } -// Add creates an entry; contest marks it as re-workable every UTC day. +// Add creates an entry; contest marks it as re-workable every UTC day. A +// trailing * makes the entry a prefix (VK9* catches every VK9…); anywhere else +// the star is refused rather than silently matching nothing. func (s *Store) Add(callsign string, contest bool) error { call := strings.ToUpper(strings.TrimSpace(callsign)) if call == "" { return fmt.Errorf("callsign required") } + if i := strings.Index(call, "*"); i >= 0 && i != len(call)-1 { + return fmt.Errorf("* is only allowed at the end (VK9*)") + } + if call == "*" { + return fmt.Errorf("a bare * would match every spot") + } s.mu.Lock() defer s.mu.Unlock() if _, ok := s.entries[call]; ok { @@ -153,9 +161,12 @@ func (s *Store) patch(callsign string, fn func(*Entry)) error { // Match returns the entry a spotted callsign belongs to, or "". // -// Prefix match, exactly as DXHunter does it: an entry RI0SP must catch -// RI0SP/MM and RI0SP/P — expeditions sign portable more often than not, and an -// exact-only match left lastSeen stale while fresh spots scrolled past. +// EXACT unless the entry says otherwise: N8W matches only N8W, and it takes +// N8W* to catch N8WCR. The first version prefix-matched everything, DXHunter +// style, and a three-letter special-event call swallowed every longer call +// sharing its start — N8W lit up for N8WCR, which is a different station +// entirely. The operator writes the star when they MEAN a family (VK9*, an +// expedition's portable forms via RI0SP*); a bare entry means that call. func (s *Store) Match(callsign string) (string, bool) { call := strings.ToUpper(strings.TrimSpace(callsign)) if call == "" { @@ -164,13 +175,22 @@ func (s *Store) Match(callsign string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() for pattern := range s.entries { - if call == pattern || strings.HasPrefix(call, pattern) { + if PatternMatches(pattern, call) { return pattern, true } } return "", false } +// PatternMatches reports whether one watchlist pattern covers a callsign: +// exact equality, or — with a trailing * — a prefix. +func PatternMatches(pattern, call string) bool { + if p, ok := strings.CutSuffix(pattern, "*"); ok { + return p != "" && strings.HasPrefix(call, p) + } + return call == pattern +} + // MarkSeen records a spot against the matching entry and reports whether the // entry wants an alert. func (s *Store) MarkSeen(callsign string) (entry string, notify bool, ok bool) { diff --git a/internal/watchlist/watchlist_test.go b/internal/watchlist/watchlist_test.go index 46f4c03..1213074 100644 --- a/internal/watchlist/watchlist_test.go +++ b/internal/watchlist/watchlist_test.go @@ -7,22 +7,36 @@ import ( "testing" ) -func TestPrefixMatch(t *testing.T) { +func TestGlobMatch(t *testing.T) { s := New(filepath.Join(t.TempDir(), "watchlist.json")) - if err := s.Add("RI0SP", false); err != nil { + if err := s.Add("RI0SP*", false); err != nil { t.Fatal(err) } - // The reason prefix matching exists: expeditions sign portable. + if err := s.Add("N8W", false); err != nil { + t.Fatal(err) + } + // A starred entry is a family: the expedition's portable forms. for _, call := range []string{"RI0SP", "RI0SP/MM", "RI0SP/P"} { if _, ok := s.Match(call); !ok { t.Errorf("Match(%q) = false, want true", call) } } - if _, ok := s.Match("RI0S"); ok { - t.Error("a SHORTER call must not match the entry") + // A bare entry is THAT call — the reported bug was N8W lighting up for + // N8WCR, a different station entirely. + if _, ok := s.Match("N8W"); !ok { + t.Error("exact entry must match its own call") } - if _, ok := s.Match("F4BPO"); ok { - t.Error("an unrelated call matched") + if _, ok := s.Match("N8WCR"); ok { + t.Error("exact entry must NOT match a longer call") + } + if _, ok := s.Match("RI0S"); ok { + t.Error("a SHORTER call must not match a starred entry") + } + if err := s.Add("VK*9", false); err == nil { + t.Error("a mid-string star must be refused") + } + if err := s.Add("*", false); err == nil { + t.Error("a bare star must be refused") } }