// 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. func (s *Store) Add(callsign string, contest bool) error { call := strings.ToUpper(strings.TrimSpace(callsign)) if call == "" { return fmt.Errorf("callsign required") } 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 "". // // 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. 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 call == pattern || strings.HasPrefix(call, pattern) { return pattern, true } } return "", false } // 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)) } }