DXHunter's header row, ported: Watchlist / Active / Needed counts up front and the All-Modes select beside the other filters (DIGI matches the digital class, SSB folds USB/LSB). Counters, card lists and the Active/Needed-only filters all read the same mode-filtered view, so the numbers add up to what is on screen. And the notify alert now asks the SAME worked-slot question the tab asks — before making a sound. It used to fire on the raw spot while the tab's verdict arrived on a debounce, so the bell rang for a slot that showed Worked a moment later. Judged in the backend at emit time: exact slot for named modes, digital class for generic DATA, today-only for contest entries.
237 lines
8.1 KiB
Go
237 lines
8.1 KiB
Go
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 (
|
|
"fmt"
|
|
"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,
|
|
})
|
|
}
|