Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fd70f6a9d | ||
|
|
51e279887d | ||
|
|
7e08553e6e | ||
|
|
3564eecc36 |
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.21.2",
|
||||||
|
"date": "2026-07-25",
|
||||||
|
"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."
|
||||||
|
],
|
||||||
|
"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."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.21.1",
|
"version": "0.21.1",
|
||||||
"date": "2026-07-24",
|
"date": "2026-07-24",
|
||||||
|
|||||||
+114
-2
@@ -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.
|
||||||
@@ -1442,6 +1448,10 @@ export default function App() {
|
|||||||
// re-populating a field the operator just cleared.
|
// re-populating a field the operator just cleared.
|
||||||
const lookupGenRef = useRef(0);
|
const lookupGenRef = useRef(0);
|
||||||
const [wb, setWb] = useState<WB | null>(null);
|
const [wb, setWb] = useState<WB | null>(null);
|
||||||
|
// Live mirror of `wb` so the lookup fallback can read the latest worked-before
|
||||||
|
// entries synchronously (the two run on separate debounce timers).
|
||||||
|
const wbRef = useRef<WB | null>(null);
|
||||||
|
useEffect(() => { wbRef.current = wb; }, [wb]);
|
||||||
const [wbBusy, setWbBusy] = useState(false);
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
|
||||||
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
|
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
|
||||||
@@ -1551,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
|
||||||
@@ -1782,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]);
|
||||||
|
|
||||||
@@ -2854,6 +2890,52 @@ export default function App() {
|
|||||||
catch { setWb(null); }
|
catch { setWb(null); }
|
||||||
finally { setWbBusy(false); }
|
finally { setWbBusy(false); }
|
||||||
}
|
}
|
||||||
|
// fillFromLastQso enriches the entry from the LAST QSO we logged with this call
|
||||||
|
// when the live lookup came up short — the callsign isn't on QRZ/HamQTH, or no
|
||||||
|
// lookup service is configured (cty.dat then gives country/zones only). It fills
|
||||||
|
// ONLY the fields the provider left empty and the operator hasn't edited, so a
|
||||||
|
// real QRZ/HamQTH hit is never overridden. `r` is the provider result (omitted
|
||||||
|
// on a lookup error, where every provider field counts as empty).
|
||||||
|
function fillFromLastQso(r?: any) {
|
||||||
|
const last: any = wbRef.current?.entries?.[0]; // entries are qso_date DESC → most recent
|
||||||
|
if (!last) return;
|
||||||
|
const ue = userEditedRef.current;
|
||||||
|
const empty = (v: any) => (v ?? '') === '';
|
||||||
|
if (!ue.has('name') && empty(r?.name) && last.name) setName(last.name);
|
||||||
|
if (!ue.has('qth') && empty(r?.qth) && last.qth) setQth(last.qth);
|
||||||
|
if (!ue.has('country') && empty(r?.country) && last.country) setCountry(last.country);
|
||||||
|
// Grid: a REAL provider grid always wins. Otherwise the last QSO's precise
|
||||||
|
// locator beats the coarse cty.dat entity centroid the provider block set — so
|
||||||
|
// a French call resolves to its real JNxx, not the country's JN16 centroid.
|
||||||
|
const adoptLastGrid = !ue.has('grid') && empty(r?.grid) && !!last.grid;
|
||||||
|
if (adoptLastGrid) setGrid(last.grid);
|
||||||
|
setDetails((d) => {
|
||||||
|
// When we adopt the last QSO's locator, take its coordinates too (derive them
|
||||||
|
// from the grid if that QSO didn't store any) so the map + saved record match.
|
||||||
|
let lat = d.lat, lon = d.lon;
|
||||||
|
if (adoptLastGrid) {
|
||||||
|
if (last.lat != null && last.lon != null) { lat = last.lat; lon = last.lon; }
|
||||||
|
else { const ll = gridToLatLon(last.grid); if (ll) { lat = ll.lat; lon = ll.lon; } }
|
||||||
|
} else {
|
||||||
|
lat = d.lat ?? (last.lat ?? undefined);
|
||||||
|
lon = d.lon ?? (last.lon ?? undefined);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
address: d.address || last.address || '',
|
||||||
|
state: d.state || last.state || '',
|
||||||
|
cnty: d.cnty || last.cnty || '',
|
||||||
|
lat, lon,
|
||||||
|
dxcc: d.dxcc ?? (last.dxcc || undefined),
|
||||||
|
cqz: d.cqz ?? (last.cqz || undefined),
|
||||||
|
ituz: d.ituz ?? (last.ituz || undefined),
|
||||||
|
cont: d.cont || last.cont || '',
|
||||||
|
email: d.email || last.email || '',
|
||||||
|
qsl_via: d.qsl_via || last.qsl_via || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (adoptLastGrid || last.grid || last.lat) setMapZoomSignal((n) => n + 1);
|
||||||
|
}
|
||||||
async function runLookup(call: string) {
|
async function runLookup(call: string) {
|
||||||
if (call !== lastLookedUpRef.current) resetAutoFill();
|
if (call !== lastLookedUpRef.current) resetAutoFill();
|
||||||
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
const gen = lookupGenRef.current; // invalidated by ESC / resetEntry
|
||||||
@@ -2901,6 +2983,9 @@ export default function App() {
|
|||||||
email: d.email || (r.email ?? ''),
|
email: d.email || (r.email ?? ''),
|
||||||
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
|
||||||
}));
|
}));
|
||||||
|
// Backfill anything the provider didn't supply from the last time we worked
|
||||||
|
// this call (call not found on QRZ/HamQTH, or lookup off → cty.dat only).
|
||||||
|
fillFromLastQso(r);
|
||||||
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
|
||||||
// The DX location is now known (grid set above) — force the world map to
|
// The DX location is now known (grid set above) — force the world map to
|
||||||
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
|
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
|
||||||
@@ -2920,6 +3005,8 @@ export default function App() {
|
|||||||
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
if (gen === lookupGenRef.current && call === callsignValRef.current.trim().toUpperCase()) {
|
||||||
setLookupResult(null);
|
setLookupResult(null);
|
||||||
setLookupError(String(e?.message ?? e));
|
setLookupError(String(e?.message ?? e));
|
||||||
|
// Lookup failed outright — still borrow from the last logged QSO.
|
||||||
|
fillFromLastQso();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Only clear the spinner if we're still the current lookup — a newer one
|
// Only clear the spinner if we're still the current lookup — a newer one
|
||||||
@@ -4275,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"
|
||||||
@@ -4746,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.
|
||||||
@@ -4845,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 1–4 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 1–4 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 1–4 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 n’est 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 1–4 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 n’est 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',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.21.1';
|
export const APP_VERSION = '0.21.2';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+9
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 3–12, at least
|
||||||
|
// one digit and one letter, only A–Z/0–9//.
|
||||||
|
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
|
||||||
|
}
|
||||||
+23
-5
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.21.1"
|
appVersion = "0.21.2"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user