feat: Super Check Partial + N+1 helper; fix Flex binding to SmartSDR CAT; telemetry callsign wait

- SCP/N+1: new internal/scp downloads the community MASTER.SCP master list and a
  docked two-column widget shows, as you type a call, the known calls containing it
  (Partial) and the calls one edit away (N+1) — click to fix a busted call. Opt-in
  in Settings → General; top-bar toggle; queried debounced on callsign input.
- Flex: OpsLog's GUI-client detection was too loose and could bind to "SmartSDR CAT"
  (or DAX) — both carry "smartsdr" in the program name — instead of the real GUI
  client, making SmartSDR CAT drop and reconnect in a loop while OpsLog was open.
  Now it binds only to a real SmartSDR/Maestro GUI client (e.g. a FLEX-8600M's
  integrated screen) and excludes cat/dax; dropped the risky empty-program fallback.
- Telemetry: on a fresh install the callsign isn't set at launch, so the once-a-day
  heartbeat recorded the machine UUID. Now it waits (~10 min) for the operator to
  enter their callsign before sending, falling back to the UUID only if none appears.
This commit is contained in:
2026-07-25 20:05:33 +02:00
parent 7e08553e6e
commit 51e279887d
12 changed files with 619 additions and 7 deletions
+93
View File
@@ -53,6 +53,7 @@ import (
"hamlog/internal/rotator/gs232" "hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst" "hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius" "hamlog/internal/rotgenius"
"hamlog/internal/scp"
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/solar" "hamlog/internal/solar"
"hamlog/internal/spe" "hamlog/internal/spe"
@@ -234,6 +235,8 @@ const (
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
keyBackupEnabled = "backup.enabled" keyBackupEnabled = "backup.enabled"
keyBackupFolder = "backup.folder" keyBackupFolder = "backup.folder"
keyBackupRotation = "backup.rotation" keyBackupRotation = "backup.rotation"
@@ -493,6 +496,7 @@ type App struct {
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign) lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
scp *scp.Manager // Super Check Partial / N+1 callsign master list
// NET Control: persistent net definitions/rosters (global JSON) + the live // NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO). // session (in-memory only — active stations currently in QSO).
@@ -985,6 +989,25 @@ func (a *App) startup(ctx context.Context) {
} }
} }
}() }()
// Super Check Partial / N+1: load the cached MASTER.SCP; when the feature is
// enabled, auto-(re)download it if missing or older than a week.
a.scp = scp.NewManager(dataDir)
go func() {
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return
}
if a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: auto-downloaded %d callsigns", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: auto-download failed: %v", err)
}
}
}()
go func() { go func() {
_ = a.clublog.EnsureLoaded() _ = a.clublog.EnsureLoaded()
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged // Auto-refresh a missing/stale country file (ClubLog adds date-ranged
@@ -2311,6 +2334,76 @@ func (a *App) DownloadLoTWUsers() (int, error) {
return a.lotwUsers.Download(a.ctx) return a.lotwUsers.Download(a.ctx)
} }
// ── Super Check Partial / N+1 ────────────────────────────────────────────────
// ScpStatus is the loaded-list summary for Settings + the widget gate.
type ScpStatus struct {
Enabled bool `json:"enabled"`
Count int `json:"count"`
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
}
// GetScpStatus returns whether SCP is enabled and how many calls are loaded.
func (a *App) GetScpStatus() ScpStatus {
st := ScpStatus{}
if a.settings != nil {
v, _ := a.settings.Get(a.ctx, keyScpEnabled)
st.Enabled = v == "1"
}
if a.scp != nil {
st.Count = a.scp.Count()
if u := a.scp.Updated(); !u.IsZero() {
st.Updated = u.UTC().Format(time.RFC3339)
}
}
return st
}
// SetScpEnabled turns Super Check Partial on/off. Enabling triggers a background
// download when the list is missing or stale.
func (a *App) SetScpEnabled(on bool) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if err := a.settings.Set(a.ctx, keyScpEnabled, boolStr(on)); err != nil {
return err
}
if on && a.scp != nil && (a.scp.Count() == 0 || time.Since(a.scp.Updated()) > 7*24*time.Hour) {
go func() {
if n, err := a.scp.Download(context.Background()); err == nil {
applog.Printf("scp: downloaded %d callsigns", n)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "scp:updated")
}
} else {
applog.Printf("scp: download failed: %v", err)
}
}()
}
return nil
}
// DownloadScp fetches the MASTER.SCP master file and caches it. Returns the
// number of callsigns loaded.
func (a *App) DownloadScp() (int, error) {
if a.scp == nil {
return 0, fmt.Errorf("not initialized")
}
return a.scp.Download(a.ctx)
}
// ScpLookup returns the Super Check Partial (substring) and N+1 (one-edit)
// suggestions for a typed fragment. Empty when SCP is disabled.
func (a *App) ScpLookup(fragment string) scp.Result {
if a.scp == nil || a.settings == nil {
return scp.Result{}
}
if v, _ := a.settings.Get(a.ctx, keyScpEnabled); v != "1" {
return scp.Result{}
}
return a.scp.Lookup(fragment, 60)
}
// StationInfoComputed bundles the data we resolve live from the // StationInfoComputed bundles the data we resolve live from the
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone, // profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
// lat/lon. Used by the Settings UI to show the "what will be stamped on // lat/lon. Used by the Settings UI to show the "what will be stamped on
+4
View File
@@ -3,9 +3,13 @@
"version": "0.21.2", "version": "0.21.2",
"date": "2026-07-25", "date": "2026-07-25",
"en": [ "en": [
"Fixed OpsLog disrupting another Flex client: it could bind to 'SmartSDR CAT' (or DAX) instead of the real GUI client, which made SmartSDR CAT keep disconnecting/reconnecting from the radio while OpsLog was open. OpsLog now only binds to the actual SmartSDR/Maestro GUI client (e.g. a FlexRadio 'M' integrated screen).",
"New: Super Check Partial + N+1 callsign helper (like N1MM/DXLog). Enable it in Settings → General to download the community MASTER.SCP list; a docked two-column widget then shows, as you type a call, the known calls that contain it (Partial) and the calls one character away (N+1) — click one to fix a busted call.",
"When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins." "When a callsign isn't found on QRZ/HamQTH (or you don't use a lookup service), the name, QTH, locator and address are recovered from the last time you worked that station — and the precise locator from that QSO is used instead of the coarse cty.dat country centroid. A real QRZ/HamQTH hit still wins."
], ],
"fr": [ "fr": [
"Correction : OpsLog pouvait perturber un autre client Flex — il pouvait se « binder » sur « SmartSDR CAT » (ou DAX) au lieu du vrai client GUI, ce qui faisait décrocher/reconnecter SmartSDR CAT de la radio en boucle tant qu'OpsLog était ouvert. OpsLog ne se binde désormais qu'au vrai client GUI SmartSDR/Maestro (ex. l'écran intégré d'un FlexRadio « M »).",
"Nouveau : assistant indicatifs Super Check Partial + N+1 (façon N1MM/DXLog). Active-le dans Réglages → Général pour télécharger la liste communautaire MASTER.SCP ; un widget ancré en 2 colonnes affiche alors, pendant que tu tapes, les indicatifs connus qui contiennent ta saisie (Partiel) et ceux à une lettre près (N+1) — clique pour corriger un call busté.",
"Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire." "Quand un indicatif est introuvable sur QRZ/HamQTH (ou si tu n'utilises pas de service de lookup), le nom, le QTH, le locator et l'adresse sont récupérés du dernier QSO avec cette station — et c'est le locator précis de ce QSO qui est utilisé, pas le centroïde du pays de cty.dat. Un vrai résultat QRZ/HamQTH reste prioritaire."
] ]
}, },
+59 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -25,6 +25,7 @@ import {
GetUltrabeamStatus, SetUltrabeamDirection, GetUltrabeamStatus, SetUltrabeamDirection,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate, GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
GetScpStatus, ScpLookup,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -71,6 +72,7 @@ import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel'; import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel'; import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
import { StatsPanel } from '@/components/StatsPanel'; import { StatsPanel } from '@/components/StatsPanel';
@@ -473,6 +475,10 @@ export default function App() {
const [agEnabled, setAgEnabled] = useState(false); const [agEnabled, setAgEnabled] = useState(false);
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false }); const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
const [tgEnabled, setTgEnabled] = useState(false); const [tgEnabled, setTgEnabled] = useState(false);
// Super Check Partial / N+1
const [scpEnabled, setScpEnabled] = useState(false);
const [scpCount, setScpCount] = useState(0);
const [scpResult, setScpResult] = useState<ScpResult>({});
// Per-port optimistic selection that the status poll must not revert until the // Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a // device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing. // click reverts the UI and the click looks like it did nothing.
@@ -1555,6 +1561,7 @@ export default function App() {
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0'); const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0'); const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
const [showScp, setShowScp] = useState(() => localStorage.getItem('opslog.showScp') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -1786,6 +1793,31 @@ export default function App() {
TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e))); TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
}; };
// Super Check Partial: poll the enabled flag + list size so the widget shows up
// once the operator turns SCP on and the master list has downloaded.
useEffect(() => {
let alive = true;
const load = async () => {
try { const s: any = await GetScpStatus(); if (alive && s) { setScpEnabled(!!s.enabled); setScpCount(s.count || 0); } } catch {}
};
load();
const id = window.setInterval(load, 3000);
const off = EventsOn('scp:updated', load);
return () => { alive = false; window.clearInterval(id); off(); };
}, []);
// Query SCP/N+1 as the callsign changes (debounced). Skipped when disabled or
// the widget is hidden, so we don't hit the backend for nothing.
useEffect(() => {
if (!scpEnabled || !showScp) { setScpResult({}); return; }
const c = callsign.trim();
if (c.length < 2) { setScpResult({}); return; }
let alive = true;
const id = window.setTimeout(async () => {
try { const r: any = await ScpLookup(c); if (alive && r) setScpResult(r as ScpResult); } catch {}
}, 120);
return () => { alive = false; window.clearTimeout(id); };
}, [callsign, scpEnabled, showScp]);
// RX band auto-follows the TX band (only differs for cross-band work). // RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]); useEffect(() => { setBandRx(band); }, [band]);
@@ -4330,6 +4362,20 @@ export default function App() {
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />} {showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button> </button>
)} )}
{scpEnabled && (
<button
type="button"
onClick={() => { const v = !showScp; setShowScp(v); writeUiPref('opslog.showScp', v ? '1' : '0'); }}
title={showScp ? 'Super Check Partial — shown · click to hide' : 'Super Check Partial · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showScp ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<SpellCheck className="size-4" />
</button>
)}
{chatAvailable && ( {chatAvailable && (
<button <button
type="button" type="button"
@@ -4801,7 +4847,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g. // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by // the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside. // the entry strip and each widget fills that height, scrolling inside.
@@ -4900,6 +4946,17 @@ export default function App() {
/> />
</div> </div>
)} )}
{showScp && scpEnabled && (
<div className="w-[240px] shrink-0 min-h-0">
<ScpPanel
result={scpResult}
currentCall={callsign}
count={scpCount}
onPick={(c) => onCallsignInput(c, { force: true })}
onClose={() => { setShowScp(false); writeUiPref('opslog.showScp', '0'); }}
/>
</div>
)}
{dvkEnabled && ( {dvkEnabled && (
<div className="w-[320px] shrink-0 min-h-0"> <div className="w-[320px] shrink-0 min-h-0">
<DvkPanel <DvkPanel
+63
View File
@@ -0,0 +1,63 @@
import { SpellCheck, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
export type ScpResult = { partial?: string[]; nplus1?: string[] };
// ScpPanel — Super Check Partial + N+1 callsign helper, split in two columns.
// Left: master calls that CONTAIN what you've typed (spot/correct a call). Right:
// calls one edit away (busted-call check). Clicking a suggestion fills the entry.
export function ScpPanel({ result, currentCall, count, onPick, onClose }: {
result: ScpResult;
currentCall: string;
count: number; // master-list size (0 = list not downloaded yet)
onPick: (call: string) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const cur = (currentCall || '').trim().toUpperCase();
const partial = result.partial ?? [];
const nplus1 = result.nplus1 ?? [];
const Col = ({ title, tone, calls, empty }: { title: string; tone: string; calls: string[]; empty: string }) => (
<div className="flex-1 min-w-0 flex flex-col">
<div className={cn('text-[10px] font-bold uppercase tracking-wider px-1.5 py-1 border-b border-border/50', tone)}>{title}</div>
<div className="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
{calls.length === 0 ? (
<div className="text-[10px] text-muted-foreground/70 italic px-1 py-1">{empty}</div>
) : calls.map((c) => (
<button key={c} type="button" onClick={() => onPick(c)}
title={t('scp.fill', { call: c })}
className={cn('w-full text-left rounded px-1.5 py-0.5 font-mono text-xs transition-colors',
c === cur ? 'bg-success/20 text-success font-bold'
: 'hover:bg-primary/15 text-foreground/90')}>
{c}
</button>
))}
</div>
</div>
);
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<SpellCheck className={cn('size-4', count > 0 ? 'text-primary' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">{t('scp.title')}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors" title={t('scp.close')}>
<X className="size-3.5" />
</button>
</div>
{count === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center text-[11px] text-muted-foreground italic text-center px-3">
{t('scp.noList')}
</div>
) : (
<div className="flex-1 min-h-0 flex divide-x divide-border/60">
<Col title={t('scp.partial')} tone="text-primary" calls={partial} empty={t('scp.typeMore')} />
<Col title="N+1" tone="text-warning" calls={nplus1} empty={t('scp.none')} />
</div>
)}
</div>
);
}
+38
View File
@@ -39,6 +39,7 @@ import {
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
GetScpStatus, SetScpEnabled, DownloadScp,
DownloadULSCounties, ULSStatus, BackfillUSCounties, DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
@@ -1253,6 +1254,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); } catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
finally { setLotwUsersBusy(false); } finally { setLotwUsersBusy(false); }
}; };
// Super Check Partial / N+1 callsign helper: enabled flag + master-list status.
const [scp, setScp] = useState<{ enabled: boolean; count: number; updated?: string }>({ enabled: false, count: 0 });
const [scpBusy, setScpBusy] = useState(false);
useEffect(() => { GetScpStatus().then((s) => setScp(s as any)).catch(() => {}); }, []);
const toggleScp = async (on: boolean) => {
setScp((s) => ({ ...s, enabled: on }));
setScpBusy(true);
try { await SetScpEnabled(on); const s = await GetScpStatus(); setScp(s as any); } catch {}
finally { setScpBusy(false); }
};
const downloadScp = async () => {
setScpBusy(true);
try { await DownloadScp(); const s = await GetScpStatus(); setScp(s as any); } catch {}
finally { setScpBusy(false); }
};
// US Counties (offline FCC ULS) — download progress arrives via events. // US Counties (offline FCC ULS) — download progress arrives via events.
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 }); const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
const [ulsBusy, setUlsBusy] = useState(false); const [ulsBusy, setUlsBusy] = useState(false);
@@ -4761,6 +4777,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} /> <Checkbox checked={showBeamMap} onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }} />
{t('gen.showBeam')} {t('gen.showBeam')}
</label> </label>
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
shows a two-column callsign helper (partial matches + one-edit calls). */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
{t('scp.enable')}
</label>
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
{scp.enabled && (
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
<ArrowDown className="size-3.5" /> {scpBusy ? t('scp.downloading') : t('scp.download')}
</Button>
<span className="text-xs text-muted-foreground">
{scp.count > 0
? t('scp.loaded', { n: scp.count.toLocaleString(), date: scp.updated ? new Date(scp.updated).toLocaleDateString() : '?' })
: t('scp.notLoaded')}
</span>
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} /> <Checkbox checked={startEqEnd} onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }} />
{t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span> {t('gen.startEqEnd')} <span className="text-xs text-muted-foreground">{t('gen.startEqEndHint')}</span>
+2
View File
@@ -283,6 +283,7 @@ const en: Dict = {
'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).', 'es.potaHint': 'Update your QSOs with the park reference from your pota.app hunter log. Paste your session token: log in at pota.app, open the browser DevTools → Network tab, click any api.pota.app request, and copy the full Authorization header value. The token expires after a while — re-copy it if the sync fails.', 'es.sessionToken': 'Session token', 'es.saving': 'Saving…', 'es.saveToken': 'Save token', 'es.potaThen': 'Then run the sync from the QSL Manager tab → service POTA hunter log (you can see and fix unmatched QSOs there).',
'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.", 'es.soon': 'soon', 'es.comingSoon': '{name} — coming soon', 'es.notWired': "This external service isn't wired up yet.",
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 14 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.', 'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 14 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
'scp.title': 'Super Check Partial', 'scp.partial': 'Partial', 'scp.close': 'Close', 'scp.fill': 'Fill {call}', 'scp.none': 'No match', 'scp.typeMore': 'Type a call…', 'scp.noList': 'Download the SCP list in Settings → General.', 'scp.enable': 'Super Check Partial / N+1 callsign helper', 'scp.settingsHint': 'Downloads the community MASTER.SCP master list and shows a two-column widget as you type a call: Partial (known calls containing what you typed) and N+1 (calls one character away) — to spot and fix a busted call.', 'scp.download': 'Update SCP list', 'scp.downloading': 'Downloading…', 'scp.loaded': '{n} calls loaded · updated {date}', 'scp.notLoaded': 'Not downloaded yet.',
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)', 'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
// Profiles panel // Profiles panel
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile', 'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
@@ -648,6 +649,7 @@ const fr: Dict = {
'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).", 'es.potaHint': "Met à jour tes QSO avec la référence du parc depuis ton carnet chasseur pota.app. Colle ton jeton de session : connecte-toi sur pota.app, ouvre les DevTools du navigateur → onglet Network, clique sur une requête api.pota.app, et copie toute la valeur de l'en-tête Authorization. Le jeton expire au bout d'un moment — recopie-le si la synchro échoue.", 'es.sessionToken': 'Jeton de session', 'es.saving': 'Enregistrement…', 'es.saveToken': 'Enregistrer le jeton', 'es.potaThen': "Puis lance la synchro depuis l'onglet Gestionnaire QSL → service POTA hunter log (tu peux y voir et corriger les QSO non appariés).",
'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.", 'es.soon': 'bientôt', 'es.comingSoon': '{name} — bientôt', 'es.notWired': "Ce service externe n'est pas encore branché.",
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 14 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce nest pas fait.', 'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 14 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce nest pas fait.',
'scp.title': 'Super Check Partial', 'scp.partial': 'Partiel', 'scp.close': 'Fermer', 'scp.fill': 'Remplir {call}', 'scp.none': 'Aucun', 'scp.typeMore': 'Tape un indicatif…', 'scp.noList': 'Télécharge la liste SCP dans Réglages → Général.', 'scp.enable': 'Assistant indicatifs Super Check Partial / N+1', 'scp.settingsHint': "Télécharge la liste communautaire MASTER.SCP et affiche un widget en 2 colonnes pendant que tu tapes un indicatif : Partiel (indicatifs connus contenant ce que tu tapes) et N+1 (indicatifs à une lettre près) — pour repérer et corriger un call busté.", 'scp.download': 'Mettre à jour la liste SCP', 'scp.downloading': 'Téléchargement…', 'scp.loaded': '{n} indicatifs chargés · maj {date}', 'scp.notLoaded': 'Pas encore téléchargée.',
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)', 'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil', 'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil', 'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
+9
View File
@@ -24,6 +24,7 @@ import {udp} from '../models';
import {lotwusers} from '../models'; import {lotwusers} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models'; import {netctl} from '../models';
import {scp} from '../models';
export function ACOMSetOperate(arg1:boolean):Promise<void>; export function ACOMSetOperate(arg1:boolean):Promise<void>;
@@ -173,6 +174,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
export function DownloadLoTWUsers():Promise<number>; export function DownloadLoTWUsers():Promise<number>;
export function DownloadScp():Promise<number>;
export function DownloadULSCounties():Promise<void>; export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
@@ -459,6 +462,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>; export function GetSPEStatus():Promise<spe.Status>;
export function GetScpStatus():Promise<main.ScpStatus>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>; export function GetSlotStats():Promise<qso.SlotStats>;
@@ -855,6 +860,8 @@ export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>; export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function ScpLookup(arg1:string):Promise<scp.Result>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>; export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
@@ -889,6 +896,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetPassphrase(arg1:string):Promise<void>; export function SetPassphrase(arg1:string):Promise<void>;
export function SetScpEnabled(arg1:boolean):Promise<void>;
export function SetTelemetryEnabled(arg1:boolean):Promise<void>; export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>; export function SetUIPref(arg1:string,arg2:string):Promise<void>;
+16
View File
@@ -298,6 +298,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers'](); return window['go']['main']['App']['DownloadLoTWUsers']();
} }
export function DownloadScp() {
return window['go']['main']['App']['DownloadScp']();
}
export function DownloadULSCounties() { export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties'](); return window['go']['main']['App']['DownloadULSCounties']();
} }
@@ -870,6 +874,10 @@ export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus'](); return window['go']['main']['App']['GetSPEStatus']();
} }
export function GetScpStatus() {
return window['go']['main']['App']['GetScpStatus']();
}
export function GetSecretStatus() { export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
@@ -1662,6 +1670,10 @@ export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1); return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
} }
export function ScpLookup(arg1) {
return window['go']['main']['App']['ScpLookup'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) { export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
} }
@@ -1730,6 +1742,10 @@ export function SetPassphrase(arg1) {
return window['go']['main']['App']['SetPassphrase'](arg1); return window['go']['main']['App']['SetPassphrase'](arg1);
} }
export function SetScpEnabled(arg1) {
return window['go']['main']['App']['SetScpEnabled'](arg1);
}
export function SetTelemetryEnabled(arg1) { export function SetTelemetryEnabled(arg1) {
return window['go']['main']['App']['SetTelemetryEnabled'](arg1); return window['go']['main']['App']['SetTelemetryEnabled'](arg1);
} }
+35
View File
@@ -2720,6 +2720,22 @@ export namespace main {
this.com_port = source["com_port"]; this.com_port = source["com_port"];
} }
} }
export class ScpStatus {
enabled: boolean;
count: number;
updated?: string;
static createFrom(source: any = {}) {
return new ScpStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.count = source["count"];
this.updated = source["updated"];
}
}
export class SecretStatus { export class SecretStatus {
has_passphrase: boolean; has_passphrase: boolean;
unlocked: boolean; unlocked: boolean;
@@ -4323,6 +4339,25 @@ export namespace qso {
} }
export namespace scp {
export class Result {
partial: string[];
nplus1: string[];
static createFrom(source: any = {}) {
return new Result(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.partial = source["partial"];
this.nplus1 = source["nplus1"];
}
}
}
export namespace solar { export namespace solar {
export class Data { export class Data {
+8 -1
View File
@@ -518,7 +518,14 @@ func (f *Flex) handleStatus(payload string) {
alreadyBound := f.boundClientID != "" alreadyBound := f.boundClientID != ""
f.mu.Unlock() f.mu.Unlock()
lp := strings.ToLower(program) lp := strings.ToLower(program)
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro") // The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
// name, so exclude them — and require an explicit GUI name (dropping the
// old program=="" fallback that could match CAT before its program field
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
if !disconnected && clientID != "" && !alreadyBound && isGUI { if !disconnected && clientID != "" && !alreadyBound && isGUI {
f.mu.Lock() f.mu.Lock()
f.boundClientID = clientID f.boundClientID = clientID
+270
View File
@@ -0,0 +1,270 @@
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
//
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
// mistyped or half-copied call surfaces the real ones.
// - N+1: master calls exactly one edit away (one char substituted, added or
// removed) from the full call you typed — the classic "did I bust it?" check.
//
// The list is downloaded once and cached on disk so it survives restarts.
package scp
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// masterURL is the community Super Check Partial master file: one callsign per
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
const cacheFile = "MASTER.SCP"
// Result is the two suggestion lists for a typed fragment.
type Result struct {
Partial []string `json:"partial"` // master calls containing the fragment
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
}
// Manager holds the parsed call list + cache location.
type Manager struct {
mu sync.RWMutex
calls []string // UPPER, de-duplicated, sorted
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
}
// NewManager loads any on-disk cache and returns a ready manager.
func NewManager(dataDir string) *Manager {
m := &Manager{
dir: dataDir,
client: &http.Client{Timeout: 60 * time.Second},
}
m.loadCache()
return m
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) loadCache() {
data, err := os.ReadFile(m.path())
if err != nil {
return
}
m.parse(data)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
m.mu.Unlock()
}
}
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
// list. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return 0, fmt.Errorf("scp: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
n := m.parse(body)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
m.mu.Lock()
m.updated = time.Now()
m.mu.Unlock()
return n, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(data []byte) int {
seen := make(map[string]struct{}, 1<<17)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the master file is one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
continue
}
seen[line] = struct{}{}
}
if len(seen) == 0 {
return 0
}
calls := make([]string, 0, len(seen))
for c := range seen {
calls = append(calls, c)
}
sort.Strings(calls)
m.mu.Lock()
m.calls = calls
m.mu.Unlock()
return len(calls)
}
// plausibleCall keeps a token that looks like a callsign: length 312, at least
// one digit and one letter, only AZ/09//.
func plausibleCall(s string) bool {
if len(s) < 3 || len(s) > 12 {
return false
}
hasDigit, hasAlpha := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
hasDigit = true
case c >= 'A' && c <= 'Z':
hasAlpha = true
case c == '/':
default:
return false
}
}
return hasDigit && hasAlpha
}
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
// (a plausible whole call) and is skipped otherwise.
func (m *Manager) Lookup(fragment string, limit int) Result {
q := strings.ToUpper(strings.TrimSpace(fragment))
if len(q) < 2 {
return Result{}
}
if limit <= 0 {
limit = 50
}
m.mu.RLock()
calls := m.calls
m.mu.RUnlock()
wantN1 := len(q) >= 3
type pm struct {
call string
prefix bool
}
var partial []pm
var nplus1 []string
for _, c := range calls {
if c == q {
partial = append(partial, pm{c, true}) // exact = strongest match
continue
}
if idx := strings.Index(c, q); idx >= 0 {
partial = append(partial, pm{c, idx == 0})
}
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
nplus1 = append(nplus1, c)
}
}
// Prefix matches first, then the rest (both already alphabetical since `calls`
// is sorted and we scanned in order).
sort.SliceStable(partial, func(i, j int) bool {
if partial[i].prefix != partial[j].prefix {
return partial[i].prefix
}
return false
})
out := Result{Partial: []string{}, NPlus1: []string{}}
for _, p := range partial {
if len(out.Partial) >= limit {
break
}
out.Partial = append(out.Partial, p.call)
}
if len(nplus1) > limit {
nplus1 = nplus1[:limit]
}
out.NPlus1 = append(out.NPlus1, nplus1...)
return out
}
// editDistanceOne reports whether a and b are exactly one edit apart (one
// substitution, insertion or deletion) — never equal (distance 0 returns false).
func editDistanceOne(a, b string) bool {
la, lb := len(a), len(b)
if la == lb {
diff := 0
for i := 0; i < la; i++ {
if a[i] != b[i] {
diff++
if diff > 1 {
return false
}
}
}
return diff == 1
}
// Ensure a is the shorter one; lengths must differ by exactly 1.
if la > lb {
a, b = b, a
la, lb = lb, la
}
if lb-la != 1 {
return false
}
// b is a with one extra char: walk both, allowing a single skip in b.
i, j, skipped := 0, 0, false
for i < la && j < lb {
if a[i] == b[j] {
i++
j++
continue
}
if skipped {
return false
}
skipped = true
j++ // skip the extra char in the longer string
}
return true
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.calls)
}
// Updated returns when the list was last refreshed (zero if never).
func (m *Manager) Updated() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.updated
}
+22 -4
View File
@@ -88,12 +88,30 @@ func (a *App) sendTelemetryHeartbeat() {
// distinct_id identifies the USER. Prefer the station callsign — it's stable // distinct_id identifies the USER. Prefer the station callsign — it's stable
// across every machine/reinstall the same operator runs, so one op counts as // across every machine/reinstall the same operator runs, so one op counts as
// one user (a callsign is public in amateur radio). The random per-install ID // one user (a callsign is public in amateur radio). The random per-install ID
// is only a fallback until a callsign is configured; without it the same op on // is only a fallback; without it the same op on a laptop + desktop + Maestro
// a laptop + desktop + Maestro showed up as three "users". install_id rides // showed up as three "users". install_id rides along as a property so multiple
// along as a property so multiple machines under one callsign stay visible. // machines under one callsign stay visible.
//
// On a FRESH INSTALL the callsign isn't configured yet at launch, so sending
// straight away would record the machine's random UUID (and the once-a-day lock
// would then keep it that way even after the op enters their call). Instead wait
// a while for the callsign to appear (Settings → Station Information) and only
// fall back to the UUID if none shows up within the grace window — so a genuine
// no-callsign install is still counted, but the normal case gets the callsign.
call := a.activeCallsign()
for i := 0; call == "" && i < 60; i++ { // up to ~10 min (60 × 10 s)
time.Sleep(10 * time.Second)
if a.settings == nil || !a.GetTelemetryEnabled() {
return // disabled meanwhile
}
if last, _ := a.settings.GetGlobal(a.ctx, keyTelemetryLastSent); strings.TrimSpace(last) == today {
return // sent by another path in the meantime
}
call = a.activeCallsign()
}
installID := a.telemetryInstallID() installID := a.telemetryInstallID()
distinctID := installID distinctID := installID
if call := a.activeCallsign(); call != "" { if call != "" {
distinctID = call distinctID = call
} }
payload := map[string]any{ payload := map[string]any{