chore: release v0.26.2

This commit is contained in:
2026-08-21 18:21:33 +02:00
parent c49faf9a14
commit 0cd243fe00
28 changed files with 1600 additions and 345 deletions
+74 -50
View File
@@ -1,7 +1,7 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
import {
@@ -20,7 +20,6 @@ import {
RotatorGoToPath,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, SetActiveRotor,
GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
@@ -56,7 +55,7 @@ import {
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef } from '@/lib/awardRefs';
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -80,6 +79,7 @@ import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { MotorAntennaWidget, type AntStatus } from '@/components/MotorAntennaWidget';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { AmpWidget } from '@/components/AmpWidget';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
@@ -598,7 +598,14 @@ export default function App() {
return () => { window.clearTimeout(t); off(); };
}, [callsign]);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
// The FULL antenna status, not the four fields the beam overlay needed: the
// docked widget shows frequency, bands and tracking too, and polling the same
// controller twice for two shapes of the same answer is how the two views end
// up disagreeing. One poll, one object.
const [ubStatus, setUbStatus] = useState<AntStatus>({
enabled: false, type: '', connected: false, direction: 0, frequency: 0, moving: false, elements: [],
});
const [showMotorAnt, setShowMotorAnt] = useState(() => localStorage.getItem('opslog.showMotorAnt') === '1');
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
const [agEnabled, setAgEnabled] = useState(false);
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
@@ -2129,8 +2136,6 @@ export default function App() {
}
}, [updateInfo]);
const [deletingAll, setDeletingAll] = useState(false);
const [ctyRefreshing, setCtyRefreshing] = useState(false);
const [refsDownloading, setRefsDownloading] = useState(false);
// === ADIF ===
const [importing, setImporting] = useState(false);
@@ -2759,7 +2764,14 @@ export default function App() {
// open, and every poll opens and closes the port.
useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);
// Poll the Ultrabeam antenna for its connection + pattern direction.
// Poll the motorised antenna: connection, pattern, frequency, tracking.
//
// pokeUbStatus re-reads it NOW rather than waiting for the next tick, so a
// button in the docked widget shows its effect immediately instead of three
// seconds later — long enough to press it a second time.
const pokeUbStatus = useCallback(async () => {
try { const s: any = await GetUltrabeamStatus(); if (s) setUbStatus(s); } catch { /* transient */ }
}, []);
useEffect(() => {
let alive = true;
const tick = async () => {
@@ -4405,7 +4417,13 @@ export default function App() {
// previous contact with the call, so the island appeared for stations
// already in the log and never for the new one on the island — which is
// the entire point of the feature.
award_refs: withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
// The island, then the Russian district — both from the callbook, both
// only ever applied to the contact being typed. See withRDARef for why
// that restriction is the whole design.
award_refs: withRDARef(
withIOTARef(d.award_refs ?? '', String((r as any)?.iota ?? '')),
String((r as any)?.rda ?? ''),
),
}));
// 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).
@@ -4481,11 +4499,21 @@ export default function App() {
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
// Someone answered: on the FIRST character of a new call (the field was
// empty), abort whatever CW is being sent — a single macro OR an auto-call
// empty), abort whatever is being sent — a single macro OR an auto-call
// CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
// Icom macro kept keying over the answering station).
if (callsignValRef.current.trim() === '') stopKeyerTx();
//
// The VOICE keyer gets the same treatment, and did not: typing a callsign
// stopped the CW auto-call and left the DVK cheerfully calling CQ over the
// station that had just answered. Nothing about the reason is specific to
// Morse — it is "somebody is there, stop calling" — so both engines stop
// on the same event.
if (callsignValRef.current.trim() === '') {
stopKeyerTx();
DVKStop().catch(() => { /* nothing playing */ });
}
stopAutoCall();
stopDvkAutoCq();
}
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry,
@@ -4629,11 +4657,10 @@ export default function App() {
{ type: 'item', label: t('tools.alerts'), action: 'tools.alerts' },
{ type: 'separator' },
{ type: 'item', label: t('tools.duplicates'), action: 'tools.duplicates', disabled: total === 0 },
{ type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
{ type: 'item', label: ctyRefreshing ? 'Refreshing cty.dat…' : 'Refresh cty.dat', action: 'tools.refreshCty', disabled: ctyRefreshing },
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
// The two maintenance entries that used to sit here — refresh cty.dat and
// download the reference lists — moved to Settings → Maintenance →
// Databases, where every other database already lives. One place to look
// beats two, and the Tools menu is for what you DO with the log.
]},
{ name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
@@ -4649,7 +4676,7 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
], [total, selectedId, selectedIds, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, smtpConfigured, sendingLog, t]);
function handleMenu(action: string) {
switch (action) {
@@ -4677,8 +4704,6 @@ export default function App() {
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
case 'tools.duplicates': setShowDuplicates(true); break;
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
case 'help.log': setShowLogViewer(true); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
@@ -4703,37 +4728,6 @@ export default function App() {
}
}
async function downloadRefs() {
if (refsDownloading) return;
setRefsDownloading(true);
setError('');
try {
const summary = await DownloadAllReferenceLists();
showToast(`Reference lists updated — ${summary}`);
} catch (e: any) {
setError(`Reference download failed: ${String(e?.message ?? e)}`);
} finally {
setRefsDownloading(false);
}
}
async function refreshCtyDat() {
if (ctyRefreshing) return;
setCtyRefreshing(true);
setError('');
try {
const info = await RefreshCtyDat();
// Use the regular error banner area for a brief success note — keeps
// us from pulling in a toast system just for one maintenance action.
setError(`cty.dat refreshed — ${info.entities} entities loaded`);
setTimeout(() => setError((e) => e.startsWith('cty.dat refreshed') ? '' : e), 4000);
} catch (e: any) {
setError(`cty.dat refresh failed: ${String(e?.message ?? e)}`);
} finally {
setCtyRefreshing(false);
}
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
@@ -6157,6 +6151,25 @@ export default function App() {
>
<Compass className="size-4" />
</button>
{ubStatus.enabled && (
<button
type="button"
onClick={() => { const v = !showMotorAnt; setShowMotorAnt(v); writeUiPref('opslog.showMotorAnt', v ? '1' : '0'); }}
title={showMotorAnt ? t('station.motorWidgetHide') : t('station.motorWidgetShow')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showMotorAnt ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<SatelliteDish className="size-4" />
{/* Amber while the elements travel: on a SteppIR that is also
when transmitting is a bad idea, so it is worth seeing from
the icon without opening the widget. */}
{ubStatus.moving && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
{!ubStatus.moving && showMotorAnt && ubStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button>
)}
{agEnabled && (
<button
type="button"
@@ -6788,7 +6801,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showMotorAnt && ubStatus.enabled) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (chaseNewOn && showChaseNew) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// 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 entry strip and each widget fills that height, scrolling inside.
@@ -6872,6 +6885,17 @@ export default function App() {
/>
</div>
)}
{showMotorAnt && ubStatus.enabled && (
<div className="w-[230px] shrink-0 min-h-0">
<MotorAntennaWidget
ant={ubStatus}
refetch={pokeUbStatus}
t={t}
essentialsOnly
onClose={() => { setShowMotorAnt(false); writeUiPref('opslog.showMotorAnt', '0'); }}
/>
</div>
)}
{showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<AntGeniusPanel