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.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user