package main // Watchlist bindings — the DXHunter watchlist concept as an OpsLog tab. // The store lives in internal/watchlist (global watchlist.json, DXHunter's own // schema); this file is the Wails boundary plus the two places the list meets // the rest of the app: the spot stream (MarkSeen + alert) and the logbook (the // worked-today answer contest entries are judged by). import ( "context" "fmt" "net/http" "strings" "time" "hamlog/internal/applog" "hamlog/internal/qso" "hamlog/internal/watchlist" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // The auto-contest pattern, DXHunter's contest_prefix: while non-empty, any // spotted callsign CONTAINING it is added to the watchlist as a contest entry // by itself — a special-event fleet (HB9WWA, DL0WWA, F4WWA…) is collected as // it appears instead of typed in one by one. Held in an atomic so the spot // pipeline never touches the settings store per spot. func (a *App) GetWatchlistContestPattern() string { if v := a.watchPattern.Load(); v != nil { return v.(string) } return "" } // SetWatchlistContestPattern stores the pattern (global, like the list itself). // Emptying it stops the auto-add; entries already collected stay — deleting an // operator's list because a setting changed is DXHunter behaviour this port // deliberately drops. func (a *App) SetWatchlistContestPattern(p string) { p = strings.ToUpper(strings.TrimSpace(p)) a.watchPattern.Store(p) a.setSettingGlobal(keyWatchlistContestPattern, p) } // WatchlistEntries returns the list for the tab. func (a *App) WatchlistEntries() []watchlist.Entry { if a.watchlist == nil { return nil } return a.watchlist.Entries() } // WatchlistAdd adds a callsign or prefix; contest entries are judged per UTC day. func (a *App) WatchlistAdd(callsign string, contest bool) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } return a.watchlist.Add(callsign, contest) } // WatchlistRemove deletes an entry. func (a *App) WatchlistRemove(callsign string) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } return a.watchlist.Remove(callsign) } // WatchlistSetNotify arms the existing alert path (sound + toast) for an entry. func (a *App) WatchlistSetNotify(callsign string, on bool) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } return a.watchlist.SetNotify(callsign, on) } // WatchlistSetContest flips the per-entry contest rule. func (a *App) WatchlistSetContest(callsign string, on bool) error { if a.watchlist == nil { return fmt.Errorf("watchlist not initialized") } return a.watchlist.SetContest(callsign, on) } // WatchlistSlotQuery asks whether one spot's slot is worked — against the whole // log for a normal entry, against TODAY (UTC) for a contest one. type WatchlistSlotQuery struct { Call string `json:"call"` Band string `json:"band"` Mode string `json:"mode"` Contest bool `json:"contest"` } // WatchlistWorkedSlots answers a batch of slot questions for the tab. // // Normal entries read the CLUSTER's own slot set — the same cached // WorkedCallSlotKeys index that colours the grid — so the watchlist can never // contradict the cluster about the same spot. That index normalises the mode // exactly as the operator configured (digital grouping on → FT4 counts as FT8's // class; off → exact mode), and the first version of this ignored that: it // asked the alerts' raw-mode index with a CLASS name, matched nothing, and // showed a fully-worked DXpedition as all Needed. // // Contest entries read TODAY's contacts instead — the midnight-UTC reset is // the query's date bound, nothing stored, nothing to reset — normalised through // the same function so the two answers use one grammar. func (a *App) WatchlistWorkedSlots(queries []WatchlistSlotQuery) []bool { out := make([]bool, len(queries)) if a.qso == nil || len(queries) == 0 { return out } idx := a.clusterStatusMaps() norm := func(m string) string { m = strings.ToUpper(strings.TrimSpace(m)) if idx.normMode != nil { m = idx.normMode(m) } return m } slotKey := func(call, band, mode string) string { up := strings.ToUpper(strings.TrimSpace(call)) b := strings.ToLower(strings.TrimSpace(band)) if m := norm(mode); m != "" { return up + "|" + b + "|" + m } return up + "|" + b } // A spot whose mode the band plan could only call "DATA" (an F/H DXpedition // off the standard dials, a comment with no mode) cannot be matched exactly: // "DATA" is in nobody's log. Such a spot is judged at digital-CLASS grain — // worked if ANY digital mode of that call is logged on that band. Claiming // NEW MODE because the label differs from FT8 was the reported bug. generic := func(m string) bool { switch strings.ToUpper(strings.TrimSpace(m)) { case "", "DATA", "DIG", "DIGI", "DIGITAL": return true } return false } digKey := func(call, band string) string { return strings.ToUpper(strings.TrimSpace(call)) + "|" + strings.ToLower(strings.TrimSpace(band)) + "|DIG" } needToday := false for _, q := range queries { if q.Contest { needToday = true break } } today := map[string]bool{} if needToday { midnight := time.Now().UTC().Truncate(24 * time.Hour) rows, err := a.qso.SlotsSince(a.ctx, midnight) if err == nil { for _, r := range rows { today[slotKey(r.Callsign, r.Band, r.Mode)] = true today[digKey(r.Callsign, r.Band)] = qso.ModeClass(r.Mode) == "DIG" || today[digKey(r.Callsign, r.Band)] } } } for i, q := range queries { if q.Contest { if generic(q.Mode) { out[i] = today[digKey(q.Call, q.Band)] } else { out[i] = today[slotKey(q.Call, q.Band, q.Mode)] } } else if generic(q.Mode) { if idx.workedCallSlotsDig != nil { _, out[i] = idx.workedCallSlotsDig[digKey(q.Call, q.Band)] } } else if idx.workedCallSlots != nil { _, out[i] = idx.workedCallSlots[slotKey(q.Call, q.Band, q.Mode)] } } return out } // watchSpot runs one live spot through the watchlist: last-seen bookkeeping and // the alert, through the SAME event the alert rules fire — the frontend already // knows how to toast and sound it, and a second notification path would be a // second thing to misconfigure. func (a *App) watchSpot(dxCall, band, mode, country, comment string, freqHz int64) { if a.watchlist == nil { return } entry, notify, ok := a.watchlist.MarkSeen(dxCall) if !ok { // Not watched yet — the auto-contest pattern may claim it. Contains, not // prefix: the event string sits anywhere in these calls (HB9WWA, F4WWA/P). if p := a.GetWatchlistContestPattern(); p != "" && strings.Contains(strings.ToUpper(dxCall), p) { if err := a.watchlist.Add(dxCall, true); err == nil { applog.Printf("watchlist: auto-added %s (contest pattern %q)", dxCall, p) entry, notify, ok = a.watchlist.MarkSeen(dxCall) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "watchlist:changed") } } } } if !ok || !notify || a.ctx == nil { return } // Already worked? Then nothing to announce. Judged HERE, before the sound — // the report was an alert ringing for a slot the tab showed as Worked a // moment later, because the frontend's verdict arrives on a debounce while // the alert used to fire on the raw spot. Same verdict as the tab: exact // slot for named modes, digital class for a generic DATA spot, today-only // for a contest entry. if e, found := a.watchlist.Get(entry); found { if a.WatchlistWorkedSlots([]WatchlistSlotQuery{{Call: dxCall, Band: band, Mode: mode, Contest: e.IsContest}})[0] { return } } // Throttled per entry: a DXpedition lights up every skimmer on the planet, // and forty alerts a minute for one station is a alarm nobody keeps on. a.watchAlertMu.Lock() last := a.watchAlertAt[entry] now := time.Now() if now.Sub(last) < 2*time.Minute { a.watchAlertMu.Unlock() return } a.watchAlertAt[entry] = now a.watchAlertMu.Unlock() wruntime.EventsEmit(a.ctx, "alert:fired", map[string]any{ "rule": "Watchlist " + entry, "call": strings.ToUpper(strings.TrimSpace(dxCall)), "band": band, "mode": mode, "freq_hz": freqHz, "country": country, "comment": comment, "sound": true, "visual": true, }) } // startWatchlistClubLog begins the expedition enrichment: every entry's // ClubLog block (expedition flag, OQRS, LiveStream, QSO totals and 24 h rate) // is refreshed on a rolling schedule — hourly for expeditions, six-hourly for // the rest, DXHunter's own cadence. OpsLog's application API key is used, so // there is nothing to configure. // // Two at a time with a breath between requests: the key is shared by every // OpsLog install, and a hundred-entry watchlist must read as a trickle at // ClubLog's end, not a burst. func (a *App) startWatchlistClubLog() { if a.watchlist == nil { return } go func() { client := &http.Client{Timeout: 30 * time.Second} // Let the app finish starting before the first network pass. time.Sleep(15 * time.Second) for { stale := a.watchlist.StaleEntries(1*time.Hour, 6*time.Hour) changed := false for _, call := range stale { // The pattern's BASE is what ClubLog knows: VK9* has no log. base := strings.TrimSuffix(call, "*") if base == "" { continue } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) d, err := watchlist.FetchWatch(ctx, client, clublogAppAPIKey, base) cancel() if err != nil { applog.Printf("watchlist clublog: %s: %v", base, err) continue } if a.watchlist.ApplyClubLog(call, d) { changed = true if d.IsExpedition { applog.Printf("watchlist clublog: %s is a DXpedition (%d QSOs, %d/24h, OQRS=%v)", base, func() int { if d.ClubLogInfo != nil { return d.ClubLogInfo.TotalQSOs } return 0 }(), d.QSOs24h(), d.HasOQRS) } } time.Sleep(400 * time.Millisecond) } if changed && a.ctx != nil { wruntime.EventsEmit(a.ctx, "watchlist:changed") } time.Sleep(10 * time.Minute) } }() }