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:
@@ -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).
|
||||
|
||||
+12
-2
@@ -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 <LOGQSO> 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 <LOGQSO> 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.",
|
||||
|
||||
@@ -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 ? (
|
||||
<Badge
|
||||
title={`ClubLog Most Wanted #${mwRank}`}
|
||||
className={cn('px-1.5 py-0 text-[10px] font-bold shrink-0',
|
||||
mwRank <= 100 ? 'bg-danger text-danger-foreground'
|
||||
: mwRank <= 250 ? 'bg-warning text-warning-foreground'
|
||||
: 'bg-muted text-muted-foreground')}
|
||||
>
|
||||
MW #{mwRank}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -154,12 +169,14 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
||||
<strong className="text-warning-muted-foreground font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||
{' '}· never worked this entity
|
||||
</span>
|
||||
{mwBadge}
|
||||
</>
|
||||
) : hasDxcc ? (
|
||||
<>
|
||||
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||
{dxccName || `DXCC #${dxcc}`}
|
||||
</Badge>
|
||||
{mwBadge}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||
|
||||
@@ -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<MWInfo>({ 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
|
||||
</div>
|
||||
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">ClubLog Most Wanted</h4>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={mwInfo.enabled}
|
||||
onCheckedChange={async (c) => {
|
||||
const v = !!c; setMwInfo((s) => ({ ...s, enabled: v })); setMwErr('');
|
||||
try {
|
||||
await SetClublogMostWantedEnabled(v);
|
||||
let info = (await GetClublogMostWantedInfo()) as MWInfo;
|
||||
// First enable with no cached list → fetch it now (for the active call).
|
||||
if (v && !info.loaded) {
|
||||
setMwBusy(true);
|
||||
try { info = (await DownloadClublogMostWanted()) as MWInfo; }
|
||||
finally { setMwBusy(false); }
|
||||
}
|
||||
setMwInfo(info);
|
||||
} catch (e: any) { setMwErr(String(e?.message ?? e)); }
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Show ClubLog "Most Wanted" rank in the entry matrix
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Displays a <strong>MW #rank</strong> pill next to the entity name (1 = the most wanted DXCC entity), fetched
|
||||
from ClubLog and <strong>personalised to your callsign</strong>. Refreshed daily.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3 pl-6">
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={mwBusy}
|
||||
onClick={() => { setMwBusy(true); setMwErr(''); DownloadClublogMostWanted().then((i) => setMwInfo(i as MWInfo)).catch((e: any) => setMwErr(String(e?.message ?? e))).finally(() => setMwBusy(false)); }}>
|
||||
{mwBusy ? 'Downloading…' : (mwInfo.loaded ? 'Update Most Wanted' : 'Download Most Wanted')}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mwInfo.loaded
|
||||
? `${mwInfo.count.toLocaleString()} entities${mwInfo.callsign ? ' · ' + mwInfo.callsign : ''}${mwInfo.date ? ' · ' + mwInfo.date : ''}`
|
||||
: 'not downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
{mwErr && <p className="text-[11px] text-destructive pl-6">{mwErr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Vendored
+6
@@ -162,6 +162,8 @@ export function DownloadAndApplyUpdate(arg1:string):Promise<void>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function DownloadClublogMostWanted():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||
|
||||
export function DownloadLoTWUsers():Promise<number>;
|
||||
@@ -374,6 +376,8 @@ export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClublogMostWantedInfo():Promise<main.ClublogMostWantedInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
|
||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
@@ -856,6 +860,8 @@ export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClublogMostWantedEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user