From a71e48f8114912ded69e557aaaaa151ce542211f Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 23 Jul 2026 20:01:28 +0200 Subject: [PATCH] feat: ClubLog Most Wanted rank in the entry band matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= (rank→DXCC JSON, real User-Agent), invert to dxcc→rank, cache to /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. --- app.go | 102 +++++++++++- changelog.json | 14 +- frontend/src/components/BandSlotGrid.tsx | 17 ++ frontend/src/components/SettingsModal.tsx | 50 ++++++ frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 22 +++ internal/clublog/mostwanted.go | 194 ++++++++++++++++++++++ internal/qso/qso.go | 4 + 9 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 internal/clublog/mostwanted.go diff --git a/app.go b/app.go index da6e724..ac73d65 100644 --- a/app.go +++ b/app.go @@ -136,6 +136,7 @@ const ( keyAwardEditsSeeded = "awards.defs.editflag" // one-shot: back-fill the user-edited flag keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions + keyClublogMostWanted = "clublog.most_wanted" // "1" → show ClubLog Most Wanted rank in the entry matrix // E-mail / SMTP — send QSO recordings to the correspondent. keyEmailEnabled = "email.enabled" @@ -464,6 +465,7 @@ type App struct { extsvc *extsvc.Manager winkeyer *winkeyer.Manager clublog *clublog.Manager + clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in) motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off @@ -954,6 +956,30 @@ func (a *App) startup(ctx context.Context) { } }() + // ClubLog "Most Wanted" DXCC ranking (opt-in, Settings → General). Loaded from + // the cached JSON, then refreshed daily and whenever the operator's callsign + // changes (the ranking is personalised per callsign). Best-effort. + a.clublogMW = clublog.NewMostWanted(dataDir) + go func() { + if a.settings == nil { + return + } + if v, _ := a.settings.Get(a.ctx, keyClublogMostWanted); v != "1" { + return // feature off — don't touch the network + } + _ = a.clublogMW.LoadFromDisk() + call := a.activeCallsign() + if a.clublogMW.NeedsRefresh(call, 24*time.Hour) { + if err := a.clublogMW.Download(context.Background(), call); err != nil { + applog.Printf("clublog most-wanted: auto-refresh failed: %v", err) + } + } + if a.clublogMW.Loaded() { + c, d, n := a.clublogMW.Info() + applog.Printf("clublog most-wanted: loaded %d entities for %s (%s)", n, c, d) + } + }() + // POTA: background poller of api.pota.app so cluster spots can be tagged // when the DX station is currently activating a park. Best-effort. a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) @@ -5247,7 +5273,13 @@ func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, err dxccHint = dxcc.EntityDXCC(m.Entity.Name) } } - return a.qso.WorkedBefore(a.ctx, callsign, dxccHint) + wb, err := a.qso.WorkedBefore(a.ctx, callsign, dxccHint) + // Attach the ClubLog Most Wanted rank for this entity (opt-in) so the entry + // matrix can show it next to the country name. + if err == nil && wb.DXCC > 0 && a.clublogMW != nil && a.clublogMostWantedEnabled() { + wb.MWRank = a.clublogMW.Rank(wb.DXCC) + } + return wb, err } // SetCompactMode toggles a tiny always-on-top window that exposes just the @@ -7327,6 +7359,74 @@ func (a *App) DownloadClublogCty() (ClublogCtyInfo, error) { return a.GetClublogCtyInfo(), nil } +// activeCallsign is the current profile's callsign (upper-cased), "" if none. +func (a *App) activeCallsign() string { + if a.profiles == nil { + return "" + } + if p, err := a.profiles.Active(a.ctx); err == nil { + return strings.ToUpper(strings.TrimSpace(p.Callsign)) + } + return "" +} + +func (a *App) clublogMostWantedEnabled() bool { + if a.settings == nil { + return false + } + v, _ := a.settings.Get(a.ctx, keyClublogMostWanted) + return v == "1" +} + +// ClublogMostWantedInfo is the UI status of the ClubLog Most Wanted data. +type ClublogMostWantedInfo struct { + Enabled bool `json:"enabled"` + Loaded bool `json:"loaded"` + Callsign string `json:"callsign"` + Date string `json:"date"` + Count int `json:"count"` +} + +// GetClublogMostWantedInfo returns the current Most Wanted status. +func (a *App) GetClublogMostWantedInfo() ClublogMostWantedInfo { + info := ClublogMostWantedInfo{Enabled: a.clublogMostWantedEnabled()} + if a.clublogMW != nil { + info.Loaded = a.clublogMW.Loaded() + info.Callsign, info.Date, info.Count = a.clublogMW.Info() + } + return info +} + +// SetClublogMostWantedEnabled toggles the Most Wanted feature. On first enable it +// loads the cached list (the UI then calls Download to fetch a fresh one). +func (a *App) SetClublogMostWantedEnabled(on bool) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + v := "0" + if on { + v = "1" + } + if err := a.settings.Set(a.ctx, keyClublogMostWanted, v); err != nil { + return err + } + if on && a.clublogMW != nil && !a.clublogMW.Loaded() { + _ = a.clublogMW.LoadFromDisk() // ok if not downloaded yet + } + return nil +} + +// DownloadClublogMostWanted fetches a fresh Most Wanted list for the active call. +func (a *App) DownloadClublogMostWanted() (ClublogMostWantedInfo, error) { + if a.clublogMW == nil { + return ClublogMostWantedInfo{}, fmt.Errorf("clublog not initialized") + } + if err := a.clublogMW.Download(a.ctx, a.activeCallsign()); err != nil { + return a.GetClublogMostWantedInfo(), err + } + return a.GetClublogMostWantedInfo(), nil +} + // applyClublogException overrides a QSO's entity fields from a ClubLog // exception matching its callsign at its date. force=true ignores the // enable toggle (used by the explicit "Update from ClubLog" action). diff --git a/changelog.json b/changelog.json index 3b9806c..0722c65 100644 --- a/changelog.json +++ b/changelog.json @@ -1,9 +1,20 @@ [ + { + "version": "0.20.12", + "date": "2026-07-23", + "en": [ + "New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.", + "Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored." + ], + "fr": [ + "Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.", + "Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable." + ] + }, { "version": "0.20.11", "date": "2026-07-23", "en": [ - "Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored.", "Fixed clicking a spot / band-map entry not moving the radio on Yaesu (and other non-Icom) rigs controlled through OmniRig — e.g. the FT-891. OpsLog tuned via OmniRig's SetSimplexMode, which on these rigs returns success but silently doesn't move the VFO (mode changes worked, so it looked like a puzzle). It now also writes the VFO frequency property directly, which does move them. Icom rigs are unchanged (there SetSimplexMode is the reliable path).", "New CW keyer engine: 'Serial port (DTR=CW / RTS=PTT)'. OpsLog can now key CW by toggling a serial control line itself — the hardware-keying method N1MM/WSJT use with a Yaesu SCU-17 and generic keying interfaces — without a K1EL WinKeyer. Settings → CW Keyer → Keyer engine: pick 'Serial port', choose the keying COM port and whether CW is on DTR (PTT on RTS) or swapped. Speed, Farnsworth, lead-in/tail and PTT keying all apply; macros, auto-CQ and work exactly as with the WinKeyer. An 'Invert keying (active-LOW)' option is there for interfaces that key backwards / transmit at rest.", "Fixed the spectrum scope never displaying on the IC-7300: its CI-V waveform frames actually carry the same leading main-scope selector byte as the dual-scope IC-7610/9700, but OpsLog assumed the 7300 omitted it — so it read the frame sequence number from the wrong byte (always 0), discarded every frame, and drew nothing. The frame layout is now detected from the data itself, so the 7300 (and other single-scope Icoms) render correctly, in both center and fixed modes. The IC-7610/9700 are unaffected.", @@ -11,7 +22,6 @@ "Closing OpsLog no longer switches off the scope display on the radio itself: turning the scope off used to send both 'stop CI-V data' and 'display off', so quitting blanked a local IC-7300's own scope screen. It now only stops the CI-V data stream on disable and never touches the radio's display (the display-on command is sent solely when enabling)." ], "fr": [ - "Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable.", "Correction : cliquer un spot / une entrée de band-map ne changeait pas la fréquence sur les Yaesu (et autres radios non-Icom) pilotées via OmniRig — p. ex. le FT-891. OpsLog accordait via SetSimplexMode d'OmniRig, qui sur ces radios renvoie « OK » mais ne bouge pas le VFO (le changement de mode marchait, d'où l'énigme). Il écrit maintenant aussi directement la propriété fréquence du VFO, ce qui les fait bien QSY. Les Icom sont inchangés (là, SetSimplexMode reste la bonne méthode).", "Nouveau moteur de keyer CW : « Port série (DTR=CW / RTS=PTT) ». OpsLog peut désormais manipuler la CW en basculant lui-même une ligne de contrôle série — la méthode de keying matériel qu'utilisent N1MM/WSJT avec un Yaesu SCU-17 et les interfaces génériques — sans WinKeyer K1EL. Réglages → Keyer CW → Moteur : choisis « Port série », le port COM de keying et si la CW est sur DTR (PTT sur RTS) ou l'inverse. Vitesse, Farnsworth, lead-in/tail et PTT s'appliquent ; les macros, l'auto-CQ et fonctionnent exactement comme avec le WinKeyer. Une option « Inverser le keying (actif-BAS) » est prévue pour les interfaces qui manipulent à l'envers / émettent au repos.", "Correction du scope spectral qui ne s'affichait jamais sur l'IC-7300 : ses trames de forme d'onde CI-V portent en réalité le même octet sélecteur de scope en tête que les IC-7610/9700 (dual-scope), mais OpsLog supposait que le 7300 ne l'envoyait pas — il lisait donc le numéro de séquence de la trame sur le mauvais octet (toujours 0), jetait toutes les trames et ne dessinait rien. La disposition des trames est maintenant détectée à partir des données elles-mêmes, donc le 7300 (et les autres Icom mono-scope) s'affichent correctement, en mode centre comme en fixe. Les IC-7610/9700 ne sont pas affectés.", diff --git a/frontend/src/components/BandSlotGrid.tsx b/frontend/src/components/BandSlotGrid.tsx index 60f5bec..278d55b 100644 --- a/frontend/src/components/BandSlotGrid.tsx +++ b/frontend/src/components/BandSlotGrid.tsx @@ -94,6 +94,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal const dxcc = wb?.dxcc ?? 0; const dxccName = wb?.dxcc_name ?? ''; const dxccCount = wb?.dxcc_count ?? 0; + const mwRank = wb?.mw_rank ?? 0; // ClubLog Most Wanted rank (1 = most wanted; 0 = off/unknown) const callCount = wb?.count ?? 0; // QSOs with this exact callsign const hasDxcc = dxcc > 0; const newOne = hasDxcc && dxccCount === 0; @@ -136,6 +137,20 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal const newMode = slotNew && bandWorked && !modeWorked; const newSlot = slotNew && bandWorked && modeWorked; + // ClubLog Most Wanted rank pill (shown next to the entity name when the feature + // is on). Hotter colour the more wanted the entity is. + const mwBadge = mwRank > 0 ? ( + + MW #{mwRank} + + ) : null; + return (
{dxccName || `DXCC #${dxcc}`} {' '}· never worked this entity + {mwBadge} ) : hasDxcc ? ( <> {dxccName || `DXCC #${dxcc}`} + {mwBadge} {dxccCount}{' '} QSO{dxccCount > 1 ? 's' : ''} with this entity diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 54fc645..08dbb7a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -17,6 +17,7 @@ import { GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, + GetClublogMostWantedInfo, SetClublogMostWantedEnabled, DownloadClublogMostWanted, GetSecretStatus, SetPassphrase, RemovePassphrase, GetEmailSettings, SaveEmailSettings, TestEmail, QSLGetEmailTemplates, QSLSaveEmailTemplates, @@ -1183,6 +1184,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [clubBusy, setClubBusy] = useState(false); const [clubErr, setClubErr] = useState(''); useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []); + // ClubLog Most Wanted (rank shown in the entry matrix). + type MWInfo = { enabled: boolean; loaded: boolean; callsign: string; date: string; count: number }; + const [mwInfo, setMwInfo] = useState({ enabled: false, loaded: false, callsign: '', date: '', count: 0 }); + const [mwBusy, setMwBusy] = useState(false); + const [mwErr, setMwErr] = useState(''); + useEffect(() => { GetClublogMostWantedInfo().then((i) => setMwInfo(i as MWInfo)).catch(() => {}); }, []); const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); }; useEffect(() => { GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {}); @@ -4781,6 +4788,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {clubErr &&

{clubErr}

} + +
+

ClubLog Most Wanted

+ +
+ + + {mwInfo.loaded + ? `${mwInfo.count.toLocaleString()} entities${mwInfo.callsign ? ' · ' + mwInfo.callsign : ''}${mwInfo.date ? ' · ' + mwInfo.date : ''}` + : 'not downloaded'} + +
+ {mwErr &&

{mwErr}

} +
); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 2415c2c..92179c8 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -162,6 +162,8 @@ export function DownloadAndApplyUpdate(arg1:string):Promise; export function DownloadClublogCty():Promise; +export function DownloadClublogMostWanted():Promise; + export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise; export function DownloadLoTWUsers():Promise; @@ -374,6 +376,8 @@ export function GetChatHistory(arg1:number):Promise>; export function GetClublogCtyInfo():Promise; +export function GetClublogMostWantedInfo():Promise; + export function GetClusterAutoConnect():Promise; export function GetClusterStatus():Promise>; @@ -856,6 +860,8 @@ export function SetCATMode(arg1:string):Promise; export function SetClublogCtyEnabled(arg1:boolean):Promise; +export function SetClublogMostWantedEnabled(arg1:boolean):Promise; + export function SetClusterAutoConnect(arg1:boolean):Promise; export function SetCompactMode(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index d22961b..6586d30 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -278,6 +278,10 @@ export function DownloadClublogCty() { return window['go']['main']['App']['DownloadClublogCty'](); } +export function DownloadClublogMostWanted() { + return window['go']['main']['App']['DownloadClublogMostWanted'](); +} + export function DownloadConfirmations(arg1, arg2, arg3) { return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3); } @@ -702,6 +706,10 @@ export function GetClublogCtyInfo() { return window['go']['main']['App']['GetClublogCtyInfo'](); } +export function GetClublogMostWantedInfo() { + return window['go']['main']['App']['GetClublogMostWantedInfo'](); +} + export function GetClusterAutoConnect() { return window['go']['main']['App']['GetClusterAutoConnect'](); } @@ -1666,6 +1674,10 @@ export function SetClublogCtyEnabled(arg1) { return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); } +export function SetClublogMostWantedEnabled(arg1) { + return window['go']['main']['App']['SetClublogMostWantedEnabled'](arg1); +} + export function SetClusterAutoConnect(arg1) { return window['go']['main']['App']['SetClusterAutoConnect'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 60e40ee..4d2b119 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1949,6 +1949,26 @@ export namespace main { this.count = source["count"]; } } + export class ClublogMostWantedInfo { + enabled: boolean; + loaded: boolean; + callsign: string; + date: string; + count: number; + + static createFrom(source: any = {}) { + return new ClublogMostWantedInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.loaded = source["loaded"]; + this.callsign = source["callsign"]; + this.date = source["date"]; + this.count = source["count"]; + } + } export class ContestBandRow { band: string; count: number; @@ -4225,6 +4245,7 @@ export namespace qso { dxcc_bands: string[]; dxcc_modes: string[]; dxcc_band_modes: BandMode[]; + mw_rank?: number; band_status: BandStatus[]; static createFrom(source: any = {}) { @@ -4249,6 +4270,7 @@ export namespace qso { this.dxcc_bands = source["dxcc_bands"]; this.dxcc_modes = source["dxcc_modes"]; this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode); + this.mw_rank = source["mw_rank"]; this.band_status = this.convertValues(source["band_status"], BandStatus); } diff --git a/internal/clublog/mostwanted.go b/internal/clublog/mostwanted.go new file mode 100644 index 0000000..d39b94e --- /dev/null +++ b/internal/clublog/mostwanted.go @@ -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= 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 +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 3ed8c6d..3cd0921 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1370,6 +1370,10 @@ type WorkedBefore struct { DXCCModes []string `json:"dxcc_modes"` DXCCBandModes []BandMode `json:"dxcc_band_modes"` + // MWRank is the ClubLog "Most Wanted" rank for this entity (1 = most wanted), + // 0 when unknown or the feature is off. Populated by the app layer, not here. + MWRank int `json:"mw_rank,omitempty"` + // Status grid driving the band×class matrix in the UI. One entry per // (band, class) where ANY QSO exists in this DXCC. Only the highest // status for that cell is kept (call_c > call_w > dxcc_c > dxcc_w).