Files
OpsLog/internal/watchlist/watchlist.go
T
rouggy c9e98d5fdd feat(watchlist): exact match by default, * for a family
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.
2026-08-29 01:25:34 +02:00

269 lines
8.0 KiB
Go

// Package watchlist is the DXHunter watchlist, transplanted: a list of
// callsigns (or prefixes) the operator is hunting, matched against the live
// spot stream, with per-entry state that survives restarts.
//
// The JSON file is DXHunter's OWN schema, field for field — including the
// ClubLog expedition block this phase does not fill yet. Deliberate: the
// operator asked to carry their watchlist.json across, and a file that
// round-trips unchanged is the whole of "same format". The file is GLOBAL
// (dataDir, beside nets.json), not per profile: a DXpedition worth hunting is
// worth hunting whichever station is on.
//
// The CONTEST idea is per entry, not a global mode as in DXHunter: an entry
// marked contest is judged against the CURRENT UTC DAY — at midnight UTC
// yesterday's contacts stop counting and every slot reads "work today" again.
// Nothing resets and nothing is stored for it: the day boundary lives in the
// query, which is why it cannot drift or need a timer.
package watchlist
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
)
// Entry is one watched callsign. JSON tags match DXHunter's watchlist.json
// exactly — see the package comment before renaming anything.
type Entry struct {
Callsign string `json:"callsign"`
LastSeen time.Time `json:"lastSeen"`
LastSeenStr string `json:"lastSeenStr"`
AddedAt time.Time `json:"addedAt"`
SpotCount int `json:"spotCount"`
IsContest bool `json:"isContest"`
Notify bool `json:"notify"`
// ClubLog expedition enrichment — phase 2. Carried so a DXHunter file
// round-trips; not filled by OpsLog yet.
IsExpedition bool `json:"isExpedition"`
ClubLogQSOs24h int `json:"clubLogQSOs24h"`
ClubLogTotalQSOs int `json:"clubLogTotalQSOs"`
ClubLogHasOQRS bool `json:"clubLogHasOQRS"`
ClubLogLiveStream bool `json:"clubLogLiveStream"`
ClubLogUpdatedAt time.Time `json:"clubLogUpdatedAt,omitempty"`
}
// Store owns the file. All methods are safe for concurrent use; writes are
// debounced so a burst of MarkSeen calls costs one disk write.
type Store struct {
mu sync.RWMutex
entries map[string]*Entry
path string
saveMu sync.Mutex
saveTimer *time.Timer
ioMu sync.Mutex
}
func New(path string) *Store {
s := &Store{entries: map[string]*Entry{}, path: path}
s.load()
return s
}
func (s *Store) load() {
data, err := os.ReadFile(s.path)
if err != nil {
return // absent on first run — created on first save
}
var list []Entry
if err := json.Unmarshal(data, &list); err != nil {
return // a corrupt file must not take the app down; it is left for repair
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range list {
e := list[i]
e.Callsign = strings.ToUpper(strings.TrimSpace(e.Callsign))
if e.Callsign == "" {
continue
}
s.entries[e.Callsign] = &e
}
}
// Entries returns a snapshot, watched-first ordering left to the UI.
func (s *Store) Entries() []Entry {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Entry, 0, len(s.entries))
for _, e := range s.entries {
c := *e
c.LastSeenStr = lastSeenLabel(c.LastSeen)
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Callsign < out[j].Callsign })
return out
}
// 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 {
return fmt.Errorf("%s is already on the watchlist", call)
}
s.entries[call] = &Entry{Callsign: call, AddedAt: time.Now(), IsContest: contest}
s.scheduleSave()
return nil
}
// Remove deletes an entry.
func (s *Store) Remove(callsign string) error {
call := strings.ToUpper(strings.TrimSpace(callsign))
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.entries[call]; !ok {
return fmt.Errorf("%s is not on the watchlist", call)
}
delete(s.entries, call)
s.scheduleSave()
return nil
}
// SetNotify arms or disarms the alert for one entry.
func (s *Store) SetNotify(callsign string, on bool) error {
return s.patch(callsign, func(e *Entry) { e.Notify = on })
}
// SetContest flips the per-entry contest rule.
func (s *Store) SetContest(callsign string, on bool) error {
return s.patch(callsign, func(e *Entry) { e.IsContest = on })
}
func (s *Store) patch(callsign string, fn func(*Entry)) error {
call := strings.ToUpper(strings.TrimSpace(callsign))
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[call]
if !ok {
return fmt.Errorf("%s is not on the watchlist", call)
}
fn(e)
s.scheduleSave()
return nil
}
// Match returns the entry a spotted callsign belongs to, or "".
//
// 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 == "" {
return "", false
}
s.mu.RLock()
defer s.mu.RUnlock()
for pattern := range s.entries {
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) {
pattern, found := s.Match(callsign)
if !found {
return "", false, false
}
s.mu.Lock()
defer s.mu.Unlock()
e := s.entries[pattern]
if e == nil {
return "", false, false
}
e.LastSeen = time.Now()
e.SpotCount++
s.scheduleSave()
return e.Callsign, e.Notify, true
}
// scheduleSave debounces the write: a burst of spots costs one file write two
// seconds after the last of them. Callers hold s.mu.
func (s *Store) scheduleSave() {
s.saveMu.Lock()
defer s.saveMu.Unlock()
if s.saveTimer != nil {
s.saveTimer.Stop()
}
s.saveTimer = time.AfterFunc(2*time.Second, func() { s.persist() })
}
func (s *Store) persist() {
list := s.Entries()
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return
}
s.ioMu.Lock()
defer s.ioMu.Unlock()
_ = os.WriteFile(s.path, data, 0o644)
}
// Flush writes now — for shutdown, where the two-second debounce would lose
// the last edits.
func (s *Store) Flush() { s.persist() }
// lastSeenLabel is DXHunter's "Just now / 5m ago / 3h ago / 2d ago" string,
// computed at read time so it never goes stale in the file.
func lastSeenLabel(t time.Time) string {
if t.IsZero() {
return "Never"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "Just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
// Get returns one entry by its exact name — the alert path needs its contest
// flag after MarkSeen named it.
func (s *Store) Get(callsign string) (Entry, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))]
if !ok {
return Entry{}, false
}
return *e, true
}