Files
OpsLog/internal/clublog/mostwanted.go
T
rouggy a71e48f811 feat: ClubLog Most Wanted rank in the entry band matrix
Opt-in (Settings → General). Shows a "MW #rank" pill next to the DXCC entity
name in the entry-strip band/slot matrix — ClubLog's Most Wanted ranking
(1 = most wanted), personalised to the operator's callsign and refreshed daily.

- internal/clublog/mostwanted.go: fetch mostwanted.php?api=1&callsign=<CALL>
  (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to
  <dataDir>/clublog_mostwanted.json; NeedsRefresh invalidates on callsign change.
- app.go: keyClublogMostWanted setting, a.clublogMW, startup refresh goroutine,
  activeCallsign() helper, and Get/Set/Download bindings mirroring the cty ones.
  WorkedBefore now carries MWRank (qso.WorkedBefore.MWRank) when enabled.
- BandSlotGrid.tsx: MW pill next to the entity name, colour-tiered by rank.
- SettingsModal General: toggle + download button + status, mirroring the
  ClubLog cty-exceptions block.

Also reorganises the changelog: the theme-persistence fix (landed after the
v0.20.11 tag) plus this feature go under a new 0.20.12 entry; 0.20.11 keeps only
what shipped in its binary.
2026-07-23 20:01:28 +02:00

195 lines
5.3 KiB
Go

package clublog
// mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per
// callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g.
// P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on
// disk (personalised to the operator's callsign) and refresh daily. The rank is
// surfaced next to the entity in the entry-strip band/slot matrix.
//
// Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON
// { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number
// Passing &callsign=<CALL> personalises the ranking to that operator's log.
// api=1 is just the "return JSON" flag — no API key is needed here.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const mwURL = "https://clublog.org/mostwanted.php?api=1"
const mwFile = "clublog_mostwanted.json"
// mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one.
const mwUserAgent = "OpsLog (amateur radio logger)"
// mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON).
type mwCache struct {
Callsign string `json:"callsign"`
FetchedAt time.Time `json:"fetched_at"`
Ranks map[string]int `json:"ranks"`
}
// MostWanted owns the on-disk cache and the parsed DXCC-number → rank map.
type MostWanted struct {
dir string
mu sync.RWMutex
ranks map[int]int // dxcc number → rank (1 = most wanted)
call string // callsign the current ranks were fetched for
fetched time.Time
}
func NewMostWanted(dataDir string) *MostWanted {
return &MostWanted{dir: dataDir, ranks: map[int]int{}}
}
func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) }
// Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded.
func (m *MostWanted) Rank(dxcc int) int {
if dxcc <= 0 {
return 0
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.ranks[dxcc]
}
// Ranks returns a copy of the whole DXCC-number → rank map.
func (m *MostWanted) Ranks() map[int]int {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[int]int, len(m.ranks))
for k, v := range m.ranks {
out[k] = v
}
return out
}
// Info reports the callsign the list was fetched for, its date, and entity count.
func (m *MostWanted) Info() (call, date string, count int) {
m.mu.RLock()
defer m.mu.RUnlock()
d := ""
if !m.fetched.IsZero() {
d = m.fetched.Format("2006-01-02")
}
return m.call, d, len(m.ranks)
}
func (m *MostWanted) Loaded() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.ranks) > 0
}
// LoadFromDisk reads the cached JSON into memory. Does NOT download.
func (m *MostWanted) LoadFromDisk() error {
b, err := os.ReadFile(m.path())
if err != nil {
return err
}
var c mwCache
if err := json.Unmarshal(b, &c); err != nil {
return err
}
ranks := make(map[int]int, len(c.Ranks))
for k, v := range c.Ranks {
if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 {
ranks[n] = v
}
}
m.mu.Lock()
m.ranks = ranks
m.call = c.Callsign
m.fetched = c.FetchedAt
m.mu.Unlock()
return nil
}
// NeedsRefresh reports whether the list is empty, older than maxAge, or was
// fetched for a DIFFERENT callsign (the ranking is personalised, so a profile
// switch invalidates it).
func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.ranks) == 0 {
return true
}
if !strings.EqualFold(strings.TrimSpace(callsign), m.call) {
return true
}
return time.Since(m.fetched) > maxAge
}
// Download fetches the most-wanted list for callsign, writes it atomically, and
// swaps it into memory.
func (m *MostWanted) Download(ctx context.Context, callsign string) error {
if err := os.MkdirAll(m.dir, 0o755); err != nil {
return err
}
call := strings.ToUpper(strings.TrimSpace(callsign))
url := mwURL
if call != "" {
url += "&callsign=" + call
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", mwUserAgent)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
// { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number.
var raw map[string]string
if err := json.Unmarshal(body, &raw); err != nil {
return fmt.Errorf("clublog mostwanted parse: %w", err)
}
ranks := make(map[int]int, len(raw))
strRanks := make(map[string]int, len(raw))
for rankStr, adif := range raw {
rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr))
dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif))
if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 {
ranks[dxcc] = rank
strRanks[adif] = rank
}
}
if len(ranks) == 0 {
return fmt.Errorf("clublog mostwanted: empty/invalid response")
}
now := time.Now()
if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil {
tmp := m.path() + ".tmp"
if os.WriteFile(tmp, b, 0o644) == nil {
_ = os.Rename(tmp, m.path())
}
}
m.mu.Lock()
m.ranks = ranks
m.call = call
m.fetched = now
m.mu.Unlock()
return nil
}