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.
269 lines
8.0 KiB
Go
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
|
|
}
|