52 Commits
Author SHA1 Message Date
rouggy 33a6342a07 chore: release v0.19.7 2026-07-15 22:22:44 +02:00
rouggy 75548812d0 fix: bug keeping location and size of the window 2026-07-15 22:22:14 +02:00
rouggy 5b96f53930 feat: While closing OpsLog will keep the same size and position for next launch 2026-07-15 22:03:42 +02:00
rouggy d354709939 feat: type freq in RX freq will send the radio for Icom and Flex 2026-07-15 13:07:51 +02:00
rouggy 3cd80ead81 fix: wrong scale on the PGXL meter 2026-07-15 12:01:12 +02:00
rouggy 9e2ffdb758 feat: Adding XIT RIT in the Flex panel 2026-07-14 23:54:06 +02:00
rouggy 9718b8a78f feat: Added DME (Diploma Municipios de Espana) award 2026-07-14 20:15:25 +02:00
rouggy ee844564de fix: changed colors on qsl info tab 2026-07-14 20:02:52 +02:00
rouggy 04b6431726 fix: Improved the code for awards 2026-07-14 16:42:48 +02:00
rouggy 0c6f8e2d68 feat: Added a test tab in awards to test the matching 2026-07-14 16:32:49 +02:00
rouggy 4fe0405432 feat: Allow updation of award catalog 2026-07-14 16:22:54 +02:00
rouggy 1f0f75baf8 feat: versioning in awards definition 2026-07-14 15:37:42 +02:00
rouggy 7f95a71426 feat: added Worked All Italian Provinces award and FFMA 2026-07-14 00:03:10 +02:00
rouggy 08f4b61523 feat: added FFMA award support 2026-07-13 22:18:25 +02:00
rouggy f5ffe81c72 chore: release v0.19.6 2026-07-13 18:04:08 +02:00
rouggy 656e238a59 feat: awards folder in the data folder, anybody can create its own awards. 2026-07-13 18:03:06 +02:00
rouggy c170d6091e feat: Reworked the awards logic so it is easy to add new ones. 2026-07-13 17:38:18 +02:00
rouggy ae60d58893 feat: added extra stats for contests 2026-07-13 16:53:37 +02:00
rouggy 68982e9a85 feat: added command sent in cluster panel 2026-07-13 13:14:34 +02:00
rouggy eb9e2db41a deat: Added statistics on your log 2026-07-13 01:29:54 +02:00
rouggy b59c6856bd feat: When in MySQL if loosing db connection contacts can still be saved
they will be synchronized with the db once connected again.
2026-07-13 00:16:58 +02:00
rouggy a00817b93e fix: while connected to MySQL if internet was lost no qso would be logged anymore 2026-07-12 18:01:03 +02:00
rouggy 7398261c50 chore: release v0.19.5 2026-07-11 10:35:54 +02:00
rouggy 73f3ec51f7 fix: Flex with multiple slices opened was always showing slice A s-meter. 2026-07-10 23:13:06 +02:00
rouggy 6c39204301 chore: release v0.19.4 2026-07-10 17:16:32 +02:00
rouggy 0c3089344b fix: Awards additional bugs 2026-07-10 16:36:20 +02:00
rouggy 6ab606fa54 fix: Bug showing split for flex while in different mode on same band 2026-07-10 15:59:32 +02:00
rouggy 96390110f8 fix: Improved awards matches 2026-07-10 15:28:01 +02:00
rouggy 6f2f9236b0 fix: bug correction in awards where a reference regex was not checked 2026-07-10 10:58:33 +02:00
rouggy efb61107fe fix: Auto download Lotw user list at launch 2026-07-09 21:13:34 +02:00
rouggy 16e780c2df fix: Added LOTW badge for Lotw users colored depending on their last upload 2026-07-09 19:32:32 +02:00
rouggy f3bf0b2f5c fix: different bugs on eQSL 2026-07-09 18:36:24 +02:00
rouggy 1f74e4d234 chore: release v0.19.3 2026-07-09 17:32:13 +02:00
rouggy 19c2de5f61 chore: release v0.19.2 2026-07-09 16:51:57 +02:00
rouggy 5ae2bad549 fix: Antenna Genius show only available antennas per band 2026-07-09 15:52:04 +02:00
rouggy e487aa78f3 fix: Awards columns persistence bug 2026-07-09 15:36:37 +02:00
rouggy b9079fe4e2 fix: bug where scope is now showing on IC7300 2026-07-09 15:30:24 +02:00
rouggy 206a6bff09 feat: Implemented the download of eQSL 2026-07-09 13:16:04 +02:00
rouggy 6ee31ae45d fix: increase width for edit qso window in French to avoid scroll bar 2026-07-09 12:10:16 +02:00
rouggy 521f8266cf feat: added full support in USB (local) & ethernet (local or remote) of audio for Icom 2026-07-09 11:30:06 +02:00
rouggy 1f5e5759cc fix: CAT PTT not working for DVK 2026-07-09 08:18:49 +02:00
rouggy f665d666d6 chore: release v0.19.1 2026-07-08 23:37:29 +02:00
rouggy 5f9ee9efa2 fix: Added missing field for bulk edit 2026-07-08 09:26:30 +02:00
rouggy 44d276c841 fix: Resizable worked before Net Control 2026-07-08 00:16:00 +02:00
rouggy e656c79589 fix: Hiding the alert bell if not active alerts 2026-07-07 22:43:02 +02:00
rouggy 86cbe94cae feat: Added worked before in Net Control 2026-07-07 22:39:17 +02:00
rouggy b9b005ea36 fix: Ultrabeam was not synchronizing after click on a spot. 2026-07-07 21:54:53 +02:00
rouggy 2c47c5d052 feat: Added additional settings in QSL Design on QSO box (opacity, bg color...) 2026-07-07 21:46:52 +02:00
rouggy 446888a330 fix: proper alignment of the version number next to OpsLog 2026-07-07 21:41:35 +02:00
rouggy 128364260b fix: bug when deleting an award reference which was not saved. 2026-07-07 21:08:26 +02:00
rouggy daf38554a5 feat: Award column can now show either reference or description 2026-07-07 20:23:18 +02:00
rouggy 39f095ae75 feat: Alerts are persistent for the 3 last ones for 10minutes 2026-07-07 19:52:52 +02:00
125 changed files with 83852 additions and 737 deletions
+1850 -146
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
return out, nil return out, nil
} }
// QSLListTemplates returns all saved templates (defaults first). // QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
// shared/global ones), defaults first — so the default is per-profile, not a
// single global list shared by every profile.
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) { func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
if a.qslTemplates == nil { if a.qslTemplates == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
recs, err := a.qslTemplates.List(a.ctx) var recs []qslcard.Record
var err error
if p, e := a.profiles.Active(a.ctx); e == nil {
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
} else {
recs, err = a.qslTemplates.List(a.ctx)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"testing"
"hamlog/internal/award"
)
// The catalog is the channel through which a shipped award is both DELIVERED and
// CORRECTED. These tests pin the three rules that make that safe:
// - a new catalog award reaches an operator who already has awards stored;
// - a fixed one (higher Version) replaces the stored copy;
// - unless the operator has edited it, in which case their work wins.
func catalogDef(t *testing.T, code string) award.Def {
t.Helper()
for _, d := range award.Defaults() {
if d.Code == code {
return d
}
}
t.Fatalf("%s is not in the catalog", code)
return award.Def{}
}
func findDef(defs []award.Def, code string) (award.Def, bool) {
for _, d := range defs {
if d.Code == code {
return d, true
}
}
return award.Def{}, false
}
func TestMergeCatalogAddsMissingAward(t *testing.T) {
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
got, updated, changed := mergeCatalog(stored)
if !changed {
t.Fatal("merge reported no change, but every catalog award was missing")
}
if len(updated) != 0 {
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
}
if _, ok := findDef(got, "FFMA"); !ok {
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
}
if _, ok := findDef(got, "MYOWN"); !ok {
t.Error("the operator's own award was dropped by the merge")
}
}
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
// The stored copy is an older revision of a shipped award, with a broken rule.
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "wrong"
old.Valid = false // the operator disabled it — a preference, not a definition
got, updated, changed := mergeCatalog([]award.Def{old})
if !changed || len(updated) == 0 {
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
}
d, _ := findDef(got, "FFMA")
if d.Field != "grid4" {
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
}
if d.Valid {
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
}
}
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "mine"
old.UserEdited = true
got, updated, _ := mergeCatalog([]award.Def{old})
if len(updated) != 0 {
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
}
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
}
}
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
prev := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
}
next := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
}
markUserEdited(next, prev)
if next[0].UserEdited {
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
}
if !next[1].UserEdited {
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
}
if next[2].UserEdited {
t.Error("C is a brand-new award; there is no shipped version to protect it from")
}
// A save must not pretend to be a new shipped revision.
if next[0].Version != 2 || next[1].Version != 2 {
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
}
}
+2 -1
View File
@@ -17,6 +17,7 @@
*/ */
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook) $DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
$DB_NAME = 'opslog'; // database name $DB_NAME = 'opslog'; // database name
$DB_USER = 'opslog'; $DB_USER = 'opslog';
$DB_PASS = 'CHANGE_ME'; $DB_PASS = 'CHANGE_ME';
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
// instead of a fatal "table doesn't exist". // instead of a fatal "table doesn't exist".
mysqli_report(MYSQLI_REPORT_OFF); mysqli_report(MYSQLI_REPORT_OFF);
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME); $mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
if ($mysqli->connect_errno) { if ($mysqli->connect_errno) {
http_response_code(500); http_response_code(500);
exit('DB error'); exit('DB error');
+441 -51
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered, AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected, OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected, SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
ContestDupe, ContestDupe,
@@ -26,6 +27,8 @@ import {
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
GetCATSettings, GetCATSettings,
GetSolarData,
LoTWUserInfo,
OperatingDefaultForBand, OperatingDefaultForBand,
LogUDPLoggedADIF, LogUDPLoggedADIF,
ListCountries, ListCountries,
@@ -64,6 +67,7 @@ import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
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 { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
@@ -366,6 +370,35 @@ export default function App() {
// CAT — receives live rig state via Wails events. // CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
// rig is off so power-ON can't work).
const [catBackend, setCatBackend] = useState('');
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
// fallback poll. These same numbers are stamped onto each logged QSO.
const [solar, setSolar] = useState<any>(null);
useEffect(() => {
const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
load();
const off = EventsOn('solar:update', load);
const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
return () => { off(); window.clearInterval(id); };
}, []);
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
useEffect(() => {
const c = callsign.trim();
if (!c) { setLotwInfo(null); return; }
const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); };
const t = window.setTimeout(run, 300);
// Re-run when the background auto-download finishes (so a call typed before
// the list loaded gets its badge without re-typing).
const off = EventsOn('lotwusers:updated', run);
return () => { window.clearTimeout(t); off(); };
}, [callsign]);
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); 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 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] }); const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
@@ -523,13 +556,20 @@ export default function App() {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; } if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none) advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]); }, [advanceToast]);
// Status-bar message expanded into a popover (long errors — a TQSL or Club Log
// failure — don't fit on one line, and truncating them with no way to read the
// rest is useless).
const [msgOpen, setMsgOpen] = useState(false);
// Reset when the message goes away, or the NEXT one would pop open by itself.
useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately. Do NOT
// dismiss while the operator has it expanded and is reading it.
useEffect(() => { useEffect(() => {
if (!error) return; if (!error || msgOpen) return;
const t = window.setTimeout(() => setError(''), 6000); const t = window.setTimeout(() => setError(''), 6000);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [error]); }, [error, msgOpen]);
// True while the QSO recorder is capturing the current contact (set when we // True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the REC badge. // leave the callsign field, cleared on log/cancel). Drives the REC badge.
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
@@ -577,6 +617,12 @@ export default function App() {
setQslTabOpen(false); setQslTabOpen(false);
setActiveTab((t) => (t === 'qsl' ? 'recent' : t)); setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
} }
// Statistics is likewise a closable tab, opened from Tools → Statistics.
const [statsTabOpen, setStatsTabOpen] = useState(false);
function closeStatsTab() {
setStatsTabOpen(false);
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
}
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller // Recent QSOs row cap, persisted. With AG Grid's virtual scroller
// huge logs render OK once loaded, but a 25k+ logbook still takes a // huge logs render OK once loaded, but a 25k+ logbook still takes a
// couple of seconds to round-trip from SQLite at launch. Defaulting // couple of seconds to round-trip from SQLite at launch. Defaulting
@@ -793,6 +839,40 @@ export default function App() {
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
// to vanish with the 3 s toast, lost if you were on another window). Each stays
// ~10 min and is clickable to tune the rig to its freq/mode.
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
// Offline outbox: QSOs parked locally because the database was unreachable.
const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
const [pendingOpen, setPendingOpen] = useState(false);
const [pendingList, setPendingList] = useState<any[]>([]);
useEffect(() => {
const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
load();
const off = EventsOn('offline:status', (s: any) => {
setOfflineStatus(s);
// Keep the open list in step as QSOs are parked or replayed.
if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
});
return () => { off?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const pendingOpenRef = useRef(false);
useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
const ALERT_TTL_MS = 10 * 60 * 1000;
useEffect(() => {
const id = window.setInterval(() => {
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
}, 20000);
return () => window.clearInterval(id);
}, []);
// Close the (now-hidden) dropdown when the last alert clears/expires.
useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster). // NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1'); const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]); useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
@@ -832,6 +912,31 @@ export default function App() {
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(''); const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
const [clusterGroup, setClusterGroup] = useState(true); const [clusterGroup, setClusterGroup] = useState(true);
const [clusterCmd, setClusterCmd] = useState(''); const [clusterCmd, setClusterCmd] = useState('');
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
// so a command appeared to do nothing at all.
type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const off = EventsOn('cluster:line', (l: any) => {
setClusterLines((prev) => {
const next = [...prev, l as ClusterLine];
return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next;
});
});
return () => { off?.(); };
}, []);
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
// to read a SH/DX reply would yank you back down on the next spot.
useEffect(() => {
const el = clusterConsoleRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
if (atBottom) el.scrollTop = el.scrollHeight;
}, [clusterLines]);
// Multi-band filter: empty set = all bands. The user toggles chips. // Multi-band filter: empty set = all bands. The user toggles chips.
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set()); const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
// Lock-to-entry: when on, the band filter follows the entry's current // Lock-to-entry: when on, the band filter follows the entry's current
@@ -994,11 +1099,15 @@ export default function App() {
// list once, then compute each shown QSO's reference per award and attach it // list once, then compute each shown QSO's reference per award and attach it
// to the rows (the grids render one hideable column per award). // to the rows (the grids render one hideable column per award).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]); const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
// Bumped whenever award definitions are saved so the grid columns AND the
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
// changing it updates ALL contacts (old included) with no restart.
const [awardsVersion, setAwardsVersion] = useState(0);
useEffect(() => { useEffect(() => {
GetAwardDefs().then((defs: any[]) => GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))), setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {}); ).catch(() => {});
}, []); }, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({}); const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => { useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean); const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
@@ -1006,7 +1115,7 @@ export default function App() {
let alive = true; let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {}); AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}, [qsos, awardCols.length]); }, [qsos, awardCols.length, awardsVersion]);
const qsosWithAwards = useMemo( const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })), () => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs], [qsos, qsoAwardRefs],
@@ -1018,7 +1127,7 @@ export default function App() {
let alive = true; let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {}); AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}, [wb, awardCols.length]); }, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo( const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null), () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs], [wb, wbAwardRefs],
@@ -1071,22 +1180,29 @@ export default function App() {
// Effective antenna heading(s): the rotor azimuth, transformed by the // Effective antenna heading(s): the rotor azimuth, transformed by the
// Ultrabeam pattern when one is active — reversed (180°) points opposite, // Ultrabeam pattern when one is active — reversed (180°) points opposite,
// bidirectional radiates both ways, normal is the heading itself. // bidirectional radiates both ways, normal is the heading itself.
// Headings are rounded to whole degrees: a rotor reports a jittering float, and
// a fraction of a degree changes nothing on a compass or a beam lobe — but it
// does invalidate every memo downstream and force the map to rebuild its whole
// overlay on each reading.
const rotorAz = useMemo<number | null>(() => (
rotatorHeading.enabled && rotatorHeading.ok
? Math.round((((rotatorHeading.azimuth % 360) + 360) % 360)) % 360
: null
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
const beamHeadings = useMemo<number[]>(() => { const beamHeadings = useMemo<number[]>(() => {
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return []; if (rotorAz == null) return [];
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
if (ubStatus.enabled && ubStatus.connected) { if (ubStatus.enabled && ubStatus.connected) {
if (ubStatus.direction === 1) return [(base + 180) % 360]; if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
if (ubStatus.direction === 2) return [base, (base + 180) % 360]; if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
} }
return [base]; return [rotorAz];
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]); }, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can // Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
// show where the antenna physically points (boom) vs where it radiates when // show where the antenna physically points (boom) vs where it radiates when
// the Ultrabeam is reversed/bidirectional. // the Ultrabeam is reversed/bidirectional.
const boomHeading = useMemo<number | null>(() => ( const boomHeading = rotorAz;
rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => { const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
if (!(ubStatus.enabled && ubStatus.connected)) return null; if (!(ubStatus.enabled && ubStatus.connected)) return null;
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal'; return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
@@ -1197,9 +1313,17 @@ export default function App() {
if (p?.visual) { if (p?.visual) {
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert'); const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? `${p.country}` : ''}`); showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? `${p.country}` : ''}`);
// Also keep it in the persistent tray (clickable, ~10 min).
const a: RecentAlert = {
id: Date.now() + Math.random(), rule, call, band,
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
};
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
} }
}); });
return () => { off(); }; return () => { off(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showToast]); }, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the // Poll PstRotator for the live antenna heading (status bar). Cheap when the
@@ -1324,6 +1448,7 @@ export default function App() {
try { try {
const c = await GetCATSettings(); const c = await GetCATSettings();
if (c.digital_default) digitalDefaultRef.current = c.digital_default; if (c.digital_default) digitalDefaultRef.current = c.digital_default;
setCatBackend(c.backend ?? '');
} catch {} } catch {}
}, []); }, []);
const loadLists = useCallback(async () => { const loadLists = useCallback(async () => {
@@ -1928,7 +2053,13 @@ export default function App() {
srx: rE.num, srx_string: rE.str, srx: rE.num, srx_string: rE.str,
}); });
} }
await AddQSO(payload); // id = -1 means the database was unreachable and the QSO was parked in the
// offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
// it as a success so logging never stops; the banner tells the operator.
const newId = await AddQSO(payload);
if (typeof newId === 'number' && newId < 0) {
showToast(t('offline.queued'));
}
// Advance the sent serial only after a successful log. // Advance the sent serial only after a successful log.
if (contest.active && contest.code && contest.exchange === 'serial') { if (contest.active && contest.code && contest.exchange === 'serial') {
updateContest({ nextSerial: contest.nextSerial + 1 }); updateContest({ nextSerial: contest.nextSerial + 1 });
@@ -2363,6 +2494,7 @@ export default function App() {
]}, ]},
{ name: 'tools', label: t('menu.tools'), items: [ { name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' }, { type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -2398,6 +2530,7 @@ export default function App() {
case 'edit.bulkedit': openBulkEdit(selectedIds); break; case 'edit.bulkedit': openBulkEdit(selectedIds); break;
case 'edit.prefs': setShowSettings(true); break; case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
@@ -2697,6 +2830,122 @@ export default function App() {
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} /> <Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div> </div>
); );
// LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
// last-upload recency (green < 1 week · amber 14 weeks · red > 30 days). The
// tooltip shows the exact last-upload date. Needs the list downloaded once
// (Settings → LoTW); until then no badge appears.
const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => {
const d = lotwInfo.days_ago ?? -1;
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
: d < 30 ? 'border-warning text-warning bg-warning/15'
: 'border-danger text-danger bg-danger/15';
return (
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
LoTW
</span>
</div>
);
})() : null;
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
// row instead of the intrusive floating cards. It lights + shows a count when
// alerts are pending; click it to see the last 3 (each clickable to tune).
// ── Offline outbox (safety net) ──────────────────────────────────────
// When the shared database is unreachable, QSOs are parked in a local ADIF
// file rather than lost. Surface that HONESTLY: the operator must know their
// worked-before check doesn't include these, and see what's waiting.
const offlinePendingCount = offlineStatus.pending ?? 0;
const offlineBlock = offlinePendingCount === 0 ? null : (
<div className="relative self-end mb-0.5 shrink-0">
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
title={t('offline.tip', { n: offlinePendingCount })}
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
<CloudOff className="size-4" />
<span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
</button>
{pendingOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
<div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
<p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
<p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
</div>
<div className="max-h-72 overflow-auto divide-y divide-border/60">
{pendingList.length === 0 ? (
<p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
) : pendingList.map((q, i) => (
<div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
<span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
<span className="text-muted-foreground w-12">{q.band}</span>
<span className="text-muted-foreground w-14">{q.mode}</span>
<span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
{q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
</span>
</div>
))}
</div>
<div className="px-3 py-2 border-t border-border flex items-center gap-2">
<button type="button"
onClick={async () => {
try {
const n = await RetryOfflineSync();
if (n > 0) showToast(t('offline.synced', { n }));
else showToast(t('offline.stillDown'));
GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
} catch (e: any) { setError(String(e?.message ?? e)); }
}}
className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
{t('offline.retry')}
</button>
<span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
</div>
</div>
</>
)}
</div>
);
const hasAlerts = recentAlerts.length > 0;
// Only show the bell when there are pending alerts — hidden otherwise.
const alertLedBlock = !hasAlerts ? null : (
<div className="relative self-end mb-0.5 shrink-0">
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
<Bell className="size-4" />
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
</button>
{alertsPanelOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
<div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
{hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
</div>
{!hasAlerts ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
) : recentAlerts.map((a) => (
<button key={a.id} type="button" title={t('alert.tuneHint')}
onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
<div className="flex items-center gap-1.5">
<span className="font-bold text-sm truncate">{a.call}</span>
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
</div>
</button>
))}
</div>
</>
)}
</div>
);
// Compact-strip Country (stacked label) + a narrow Comment. // Compact-strip Country (stacked label) + a narrow Comment.
const countryBlockSm = ( const countryBlockSm = (
<div className="flex flex-col w-36"> <div className="flex flex-col w-36">
@@ -2748,6 +2997,18 @@ export default function App() {
</div> </div>
); );
// CQ/ITU zones moved to the Info (F2) tab (DetailsPanel). // CQ/ITU zones moved to the Info (F2) tab (DetailsPanel).
// Type a frequency, press Enter → tune the radio there. Only when a rig is
// actually connected (these same fields are the manual-log entry when it isn't),
// and only on a plausible HF/VHF value so a half-typed "14." doesn't QSY the rig
// to 14 kHz. noteManualEdit() holds off the incoming poll so the field doesn't
// snap back before the radio's echo confirms the move.
const tuneRadioTo = (mhzStr: string) => {
if (!(catState.enabled && catState.connected)) return;
const mhz = parseFloat(mhzStr);
if (!Number.isFinite(mhz) || mhz < 0.1 || mhz > 3000) return;
noteManualEdit();
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
};
const freqBlock = ( const freqBlock = (
<div className="flex flex-col w-32"> <div className="flex flex-col w-32">
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label> <Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
@@ -2756,8 +3017,10 @@ export default function App() {
className="font-mono" className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')} value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250" placeholder="14.250"
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)} onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)} onBlur={() => setFreqFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(freqMhz); }}
onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }} onChange={(e) => { setFreqMhz(e.target.value); noteManualEdit(); const b = bandForMHz(parseFloat(e.target.value)); if (b) setBand(b); }}
/> />
</div> </div>
@@ -2769,8 +3032,10 @@ export default function App() {
tabIndex={-1} tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')} value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255" placeholder="14.255"
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)} onFocus={() => setFreqFocused(true)}
onBlur={() => setFreqFocused(false)} onBlur={() => setFreqFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') tuneRadioTo(rxFreqMhz); }}
onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }} onChange={(e) => { setRxFreqMhz(e.target.value); noteManualEdit(); const rb = bandForMHz(parseFloat(e.target.value)); if (rb) setBandRx(rb); }}
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')} className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
/> />
@@ -3083,7 +3348,7 @@ export default function App() {
case 'icom': case 'icom':
return ( return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div> </div>
); );
case 'netcontrol': case 'netcontrol':
@@ -3145,35 +3410,20 @@ export default function App() {
</Button> </Button>
</header> </header>
) : ( ) : (
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm"> <header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
<div className="flex items-center gap-2 pr-2 border-r border-border/60"> <div className="flex items-center gap-2 pr-2 border-r border-border/60">
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" /> <div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
<span className="font-bold text-[15px] tracking-tight">OpsLog</span> <div className="flex items-baseline gap-1.5">
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span> <span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
<span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
</div>
</div> </div>
<Menubar menus={menus} onAction={handleMenu} /> <Menubar menus={menus} onAction={handleMenu} />
<div className="relative flex items-center justify-center gap-2 font-mono"> <div className="relative flex items-center justify-center gap-2 font-mono">
{/* Transient toast / error, in the empty band between the menu and {/* Toasts and errors live in the STATUS BAR at the bottom now — the
the frequency (left of centre). Single-line + truncated. */} header band was too narrow and long messages were cut off. */}
{(error || toast) && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
{error ? (
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate" title={error}>{error}</span>
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
</div>
) : (
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
</div>
)}
</div>
)}
<div className="flex flex-col items-end leading-none"> <div className="flex flex-col items-end leading-none">
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span> <span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && ( {catState.split && rxFreqMhz && (
@@ -3253,10 +3503,10 @@ export default function App() {
); );
})()} })()}
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */} {/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && ( {ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1" <div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}> title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings"> <button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} /> <span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
</button> </button>
@@ -3366,6 +3616,34 @@ export default function App() {
)} )}
</div> </div>
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
onto each logged QSO. Always renders one element so the grid columns
stay aligned (empty span until the feed loads). */}
{solar && solar.ok ? (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
{(() => {
const geo = String(solar.geomag_field || '').toUpperCase();
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
const it = (label: string, val: any, cls = 'text-foreground') => (
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
<span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
</span>
);
return (<>
{it('SFI', solar.sfi)}
{it('SSN', solar.ssn)}
{it('A', solar.a_index)}
{it('K', solar.k_index)}
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
</>);
})()}
</div>
) : <span />}
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60"> <div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
<Clock className="size-3" /> <Clock className="size-3" />
{utcNow}<span className="text-[10px]">UTC</span> {utcNow}<span className="text-[10px]">UTC</span>
@@ -3579,13 +3857,17 @@ export default function App() {
</div> </div>
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so {/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
its right edge lines up with that column below; QTH grows to fill. */} its right edge lines up with that column below; QTH grows to fill.
<div className="flex gap-2 items-end"> gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
<div className="flex gap-4 items-end">
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label> <div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} /> <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div> </div>
{qthBlock} {qthBlock}
{gridBlock} {gridBlock}
{lotwBlock}
{offlineBlock}
{alertLedBlock}
</div> </div>
{/* Row 3: tight left detail column (Band/Mode/Country) and {/* Row 3: tight left detail column (Band/Mode/Country) and
@@ -3679,6 +3961,7 @@ export default function App() {
status={agStatus} status={agStatus}
onActivate={agActivate} onActivate={agActivate}
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }} onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
band={band}
/> />
</div> </div>
)} )}
@@ -3857,6 +4140,21 @@ export default function App() {
)} )}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>} {catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{statsTabOpen && (
<TabsTrigger value="stats" className="gap-1.5">
{t('stats.tab')}
<span
role="button"
aria-label="Close Statistics"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeStatsTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{qslTabOpen && ( {qslTabOpen && (
<TabsTrigger value="qsl" className="gap-1.5"> <TabsTrigger value="qsl" className="gap-1.5">
QSL Manager QSL Manager
@@ -4086,8 +4384,48 @@ export default function App() {
); );
})()} })()}
{/* Console — the RAW cluster stream. Spots are parsed out of it into
the grid above, but SH/DX, WHO, the MOTD and error replies are not
spots: without this they were dropped and the command box looked
inert (you typed SH/DX/100 and nothing ever happened). */}
{clusterConsoleOpen && (
<div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
<div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
<Terminal className="size-3.5 text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t('cluster.console')}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
<div className="flex-1" />
<button className="text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
<button className="text-muted-foreground hover:text-foreground"
onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
<X className="size-3.5" />
</button>
</div>
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
{clusterLines.length === 0 ? (
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
) : clusterLines.map((l, i) => (
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
{l.text}
</div>
))}
</div>
</div>
)}
{/* Command input — sends to the master server. */} {/* Command input — sends to the master server. */}
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0"> <div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
{!clusterConsoleOpen && (
<button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
<Terminal className="size-3.5" />
</button>
)}
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span> <span className="text-xs text-muted-foreground font-mono whitespace-nowrap"> master</span>
<Input <Input
className="font-mono text-xs h-8" className="font-mono text-xs h-8"
@@ -4148,9 +4486,15 @@ export default function App() {
</TabsContent> </TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0"> <TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel onEditQSO={openEdit} /> <AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
</TabsContent> </TabsContent>
{statsTabOpen && (
<TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StatsPanel />
</TabsContent>
)}
{contestTabEnabled && ( {contestTabEnabled && (
<TabsContent value="contest" className="flex-1 min-h-0 p-0"> <TabsContent value="contest" className="flex-1 min-h-0 p-0">
<ContestPanel session={contest} onChange={updateContest} /> <ContestPanel session={contest} onChange={updateContest} />
@@ -4170,7 +4514,7 @@ export default function App() {
is an Icom. */} is an Icom. */}
{catState.backend === 'icom' && ( {catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0"> <TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent> </TabsContent>
)} )}
@@ -4271,7 +4615,7 @@ export default function App() {
</button> </button>
); );
return ( return (
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0"> <footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong> QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
</span> </span>
@@ -4290,7 +4634,48 @@ export default function App() {
disabled={!rotatorHeading.enabled} disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/> />
<div className="flex-1" /> {/* Toasts / errors: the status bar's free space is far wider than the
header band they used to sit in. Still one line (the bar is 28px),
but CLICK opens the full text wrapped — long messages (a TQSL or
Club Log failure) are no longer cut off with no way to read them. */}
<div className="flex-1 min-w-0 flex items-center gap-1 px-1">
{(error || toast) && (
<>
<button
type="button"
onClick={() => setMsgOpen((o) => !o)}
title={t('msg.expand')}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
error
? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
: 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
)}
>
{error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
<span className="truncate">{error || toast}</span>
</button>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
>
<X className="size-3" />
</button>
{msgOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
<div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
<p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
error ? 'text-destructive' : 'text-foreground')}>
{error || toast}
</p>
</div>
</>
)}
</>
)}
</div>
{dbConn && ( {dbConn && (
<button <button
type="button" type="button"
@@ -4322,9 +4707,14 @@ export default function App() {
onClose={() => setShowSpotModal(false)} onClose={() => setShowSpotModal(false)}
// Callsign: the QSO-entry call, else the last logged QSO. // Callsign: the QSO-entry call, else the last logged QSO.
defaultCall={callsign.trim() || qsos[0]?.callsign || ''} defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
// Freq: the entry TX freq (kHz), else the last logged QSO's. // Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
// freqMhz display string, which the manual-edit freeze / field locks can
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
// string spotted 14134). Fall back to the entry field, then the last QSO.
defaultFreqKHz={ defaultFreqKHz={
parseFloat(freqMhz) > 0 catState.connected && (catState.freq_hz ?? 0) > 0
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
: parseFloat(freqMhz) > 0
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10 ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0) : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
} }
+40 -4
View File
@@ -1,14 +1,30 @@
import { Antenna, X } from 'lucide-react'; import { useState } from 'react';
import { Antenna, X, ListFilter } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
export type AGAntenna = { index: number; name: string }; export type AGAntenna = { index: number; name: string; bands?: number };
export type AGStatus = { export type AGStatus = {
connected: boolean; host?: string; last_error?: string; connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean; port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[]; antennas: AGAntenna[];
}; };
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
// configured for the current band, like the native app. If the mapping ever
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
// device's actual mask so it can be corrected.
const AG_BAND_BITS: Record<string, number> = {
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
};
function bandBit(band?: string): number {
if (!band) return 0;
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
}
// Format an antenna name: first letter uppercase, the rest lowercase // Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander"). // (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string { function pretty(name: string): string {
@@ -22,13 +38,28 @@ function pretty(name: string): string {
// a port-A button (left) and port-B button (right). Colours: green = selected on // a port-A button (left) and port-B button (right). Colours: green = selected on
// port A, blue = selected on port B, red (pulsing) = that port is transmitting. // port A, blue = selected on port B, red (pulsing) = that port is transmitting.
// Clicking an already-selected port deselects it (port → None). // Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: { export function AntGeniusPanel({ status, onActivate, onClose, band }: {
status: AGStatus; status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void; onClose: () => void;
band?: string; // current operating band (e.g. "20m") for the band filter
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const list = status.antennas ?? []; const allAntennas = status.antennas ?? [];
// Band filter (persisted, default ON): show only antennas whose band mask
// includes the current band — matching the native 4O3A app. Antennas with an
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
// band we can't map, or masks the device didn't send) we fall back to the full
// list so the panel is never mysteriously empty.
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
const bit = bandBit(band);
let list = allAntennas;
if (bandFilter && bit !== 0) {
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => { const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B'; const letter = port === 1 ? 'A' : 'B';
@@ -57,6 +88,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} /> <Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span> <span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" /> <span className="flex-1" />
<button type="button" onClick={toggleBandFilter}
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
<ListFilter className="size-3.5" />
</button>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider"> <span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span> <span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
+371 -31
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react'; import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -18,7 +18,8 @@ import {
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName, ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences, PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and // Above this many references the editor stops loading the whole list and
@@ -30,8 +31,9 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
export type AwardDef = { export type AwardDef = {
code: string; name: string; description?: string; valid?: boolean; protected?: boolean; code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string; url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string; type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[]; leading_str?: string; trailing_str?: string; dynamic?: boolean;
or_rules?: AwardOrRule[]; or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean; confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
@@ -176,7 +178,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
loadMeta(); loadMeta();
}, [open]); }, [open]);
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
// here exists only on this machine — it is yours alone, and a reinstall loses it
// unless it has been exported. The list flags those.
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
useEffect(() => {
if (!open) return;
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
}, [open]);
// Shipped fixes we did NOT apply, because this award carries the operator's own
// changes and we will not overwrite those behind their back. Offered, not forced.
type AwardUpdate = { code: string; name: string; from: number; to: number };
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
const loadUpdates = useCallback(() => {
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
}, []);
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
// Pending import awaiting the operator's decision on the awards that collide.
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
type ImportPreview = { path: string; awards: ImportEntry[] };
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
const [decisions, setDecisions] = useState<Record<string, string>>({});
useEffect(() => {
if (!importPreview) return;
// Default to the SAFE choice: keep what you have. An import must never destroy
// an award because the operator clicked through a dialog without reading it.
const d: Record<string, string> = {};
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
setDecisions(d);
}, [importPreview]);
const cur = defs[sel]; const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
type Step = {
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
skipped?: boolean; error?: string;
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
const [testErr, setTestErr] = useState('');
const [testing, setTesting] = useState(false);
const runTest = async () => {
if (!cur) return;
setTesting(true); setTestErr(''); setTestRows(null);
try {
const r = await ExplainAward(cur.code, testCall);
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
} catch (e: any) {
setTestErr(String(e?.message ?? e));
} finally {
setTesting(false);
}
};
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
// so, rather than let the operator test a rule they only think they applied.
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
// of the same trace is noise. But they don't always: scope is judged per QSO
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
// two contacts can even hold different QTHs. That divergence is often the actual
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
// trace instead of dropping it: one card per distinct outcome, with its count.
const testGroups = useMemo(() => {
if (!testRows) return null;
const groups: { key: string; rows: TestRow[] }[] = [];
for (const row of testRows) {
const key = JSON.stringify(row.explanation);
const g = groups.find((x) => x.key === key);
if (g) g.rows.push(row);
else groups.push({ key, rows: [row] });
}
return groups;
}, [testRows]);
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d))); const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => { const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []); const arr = ((cur?.[key] as string[]) ?? []);
@@ -214,17 +299,39 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p })); if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
// Import an award bundle: definitions are upserted by code, reference lists // Import: LOOK FIRST, then ask.
// replaced. Reloads the editor afterwards. //
// This used to merge by code with "imported wins", silently — import a WAPC
// someone shared and YOUR WAPC (its province list, its city regexes) was
// destroyed without a word. Sharing awards is exactly what we want people to do,
// so it must not be a data-loss trap.
async function importAwards() { async function importAwards() {
setErr(''); setErr('');
try { try {
const r: any = await ImportAwards(); const p: any = await InspectAwardImport();
if (!r || (!r.awards && !r.references)) return; // cancelled if (!p?.path) return; // cancelled
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
if (clashes.length === 0) {
// Nothing collides — nothing to ask. This is the common case.
const dec: Record<string, string> = {};
for (const e of p.awards) dec[e.code] = 'replace';
await applyImport(p.path, dec);
return;
}
setImportPreview(p); // → the collision dialog decides
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function applyImport(path: string, decisions: Record<string, string>) {
try {
const r: any = await ApplyAwardImport(path, decisions);
setImportPreview(null);
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]); const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
setDefs((d ?? []) as any); setSel(0); setDefs((d ?? []) as any); setSel(0);
onSaved(); onSaved();
if (r?.awards || r?.references) {
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references })); setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
}
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function updateList(code: string) { async function updateList(code: string) {
@@ -260,15 +367,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{filtered.map(({ d, i }) => ( {filtered.map(({ d, i }) => {
// An award that is NOT in the shipped catalog exists only in THIS
// database: nobody else has it, and a reinstall loses it unless it
// has been exported. That deserves to be visible at a glance, not
// discovered the hard way.
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
// A pending update is only reachable from the award's own banner, so
// the list has to say which award to open — otherwise the fix waits
// behind a click nobody knows to make.
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
return (
<button key={i} onClick={() => setSel(i)} <button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30', className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}> i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} /> <span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span> <span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span> <span className="text-muted-foreground truncate">{d.name}</span>
{hasUpdate && (
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
<ArrowUpCircle className="size-3.5 text-info" />
</span>
)}
{onlyHere && (
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
title={t('awed.onlyHereTip')}>
{t('awed.onlyHere')}
</span>
)}
</button> </button>
))} );
})}
</div> </div>
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}> <Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')} <Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
@@ -278,6 +407,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */} {/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>} {err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
plainly what accepting costs. */}
{cur && selUpdate && (
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<ArrowUpCircle className="size-4 text-info shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">{t('awed.updateAvailable')}</div>
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
</div>
<Button size="sm" className="h-7 px-2 text-[11px]"
onClick={async () => {
try {
await ApplyAwardUpdate(cur.code);
// The backend rewrote this award (and its references) — pull the
// new state back, or the editor would keep showing, and on the
// next Save re-persist, the definition we just replaced.
setDefs(((await GetAwardDefs()) ?? []) as any);
loadMeta();
loadUpdates();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateApply')}</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
onClick={async () => {
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateKeepMine')}</Button>
</div>
</div>
)}
{!cur ? ( {!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div> <div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : ( ) : (
@@ -287,6 +447,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger> <TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger> <TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger> <TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
</TabsList> </TabsList>
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
@@ -296,11 +457,30 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" /> <Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} /> <Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder={t('awed.awardName')} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label> <label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> {t('awed.valid')}</label>
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
the catalog derives that on load. Asking the author to tick a
box to declare it would be one more step nobody can guess —
forget it and the award silently misses every future catalog
correction. "Protected" stays: whether an award can be deleted
IS a real choice. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
</label>
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button> <button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
</div> </div>
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2> <Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2> <Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2> <Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
<Field2 label={t('awed.refDisplay')}>
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
</SelectContent>
</Select>
</Field2>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2> <Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2> <Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
@@ -327,7 +507,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}</SelectContent> }</SelectContent>
</Select> </Select>
</Field2> </Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label> {/* No "allow multiple references" switch: a QSO always counts for
every reference its field holds (an n-fer POTA activation, a
VUCC grid-line contact). The old checkbox was read by nothing. */}
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label> <label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5"> <div className="border-t pt-2.5 mt-1 space-y-2.5">
@@ -354,8 +536,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2> <Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div> </div>
{/* Additional OR searches: a QSO earns a reference if the {/* Fallback searches: tried in order, only while nothing
primary rule OR any of these match. */} has matched yet — the first that hits wins (short-circuit),
so a value already resolved by the primary rule isn't
re-derived differently by a later one. */}
<div className="border-t pt-2.5 space-y-2"> <div className="border-t pt-2.5 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p> <p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
@@ -412,8 +596,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))} ))}
</div> </div>
</div> </div>
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2> {/* "Grant codes" and "export credit_granted" used to live here. No
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label> ADIF export has ever written CREDIT_GRANTED, so both controls
did nothing at all. The stored values are kept (see award.Def);
put the controls back when the export is actually wired up. */}
</TabsContent>
{/* ── Test ──
Runs the SAVED award against a real QSO and shows every rule:
what it scanned, what it produced, and why each candidate was
rejected. An award that matches nothing used to be a black box. */}
<TabsContent value="test" className="mt-0 space-y-3">
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
</div>
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
{t('awed.testRun')}
</Button>
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
</div>
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
{testGroups?.map((g, ri) => {
const row = g.rows[0];
const ex = row.explanation;
const matched = (ex.result ?? []).length > 0;
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
return (
<div key={ri} className="rounded border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
{g.rows.length > 1 && (
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
</span>
)}
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
</span>
</div>
{!ex.in_scope ? (
<div className="px-3 py-2 text-xs">
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
<span className="text-muted-foreground">{ex.scope_error}</span>
</div>
) : (
<div className="divide-y divide-border/50">
{(ex.steps ?? []).map((s, si) => (
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
<span className="text-muted-foreground">
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
</span>
{s.skipped && <span className="text-muted-foreground italic"> {t('awed.testSkipped')}</span>}
{s.error && <span className="text-destructive"> {s.error}</span>}
</div>
{!s.skipped && !s.error && (
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
<div>
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
{s.field_value
? <span className="font-mono">{s.field_value}</span>
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
</div>
{(s.kept ?? []).map((k) => (
<div key={k} className="text-success font-mono"> {k}</div>
))}
{(s.rejected ?? []).map((r, i2) => (
<div key={i2} className="font-mono text-muted-foreground">
{r.candidate} <span className="font-sans"> {r.reason}</span>
</div>
))}
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
)}
</div>
)}
</div>
))}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
</div>
)}
</div>
)}
</div>
);
})}
</TabsContent> </TabsContent>
{/* ── References ── */} {/* ── References ── */}
@@ -438,11 +719,78 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}> <Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
<Upload className="size-3.5 mr-1" /> {t('awed.import')} <Upload className="size-3.5 mr-1" /> {t('awed.import')}
</Button> </Button>
{/* The drop folder: put an award JSON here and it installs on restart —
no rebuild, nobody to ask. Opening it beats printing a path someone
then has to retype. */}
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
<div className="flex-1" /> <div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button> <Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button> <Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* Collision dialog. The import cannot silently replace an award you already
have — importing a shared WAPC used to destroy yours (province list, city
regexes, band scope) without a word. You decide, per award. */}
{importPreview && (
<Dialog open onOpenChange={() => setImportPreview(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
{importPreview.awards.map((e) => (
<div key={e.code} className="rounded-md border border-border p-2.5">
<div className="flex items-baseline gap-2 min-w-0">
<span className="font-mono font-semibold text-sm">{e.code}</span>
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
{t('awed.importRefs', { n: e.references })}
</span>
</div>
{!e.exists ? (
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
) : (
<>
<p className="mt-1 text-[11px] text-warning-muted-foreground">
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
{e.protected && ` · ${t('awed.importProtected')}`}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{([
['skip', t('awed.importKeepMine')],
['replace', t('awed.importReplace')],
['copy', t('awed.importCopy', { code: e.code })],
] as [string, string][]).map(([v, label]) => (
<button key={v} type="button"
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
className={cn('px-2 h-7 rounded-md border text-xs',
decisions[e.code] === v
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-muted')}>
{label}
</button>
))}
</div>
</>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
<Button onClick={() => applyImport(importPreview.path, decisions)}>
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Dialog> </Dialog>
); );
} }
@@ -595,26 +943,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button> <button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
</div> </div>
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2> <Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<div className="grid grid-cols-2 gap-3"> {/* One per row: side by side, each half spends 120px of its width on
the label column and the input is left too narrow to read a group
name ("Basilicata" → "Basili"). */}
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2> <Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2> <Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
</div>
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2> <Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2> <Field2 label={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
<div className="grid grid-cols-3 gap-3"> {/* Score / Bonus were here. Nothing computes an award score, so both
<div className="flex flex-col gap-1 min-w-0"> boxes were pure decoration. The columns stay in the database — a
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label> third-party list may carry the values — but they are not offered
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /> for editing until something actually reads them. */}
</div> <Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div> <div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div> </div>
)} )}
+2 -2
View File
@@ -61,7 +61,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] }; type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) { export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
const { t } = useI18n(); const { t } = useI18n();
const [awardList, setAwardList] = useState<AwardListItem[]>([]); const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the // Computed results are cached per award code — each award is scanned only the
@@ -220,7 +220,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{t('awp.rescan')} {t('awp.rescan')}
</Button> </Button>
</div> </div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} /> <AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
{/* Quick selector — scales when there are many awards. */} {/* Quick selector — scales when there are many awards. */}
{awardList.length > 0 && ( {awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40"> <div className="px-2 py-2 border-b border-border/40">
+27 -3
View File
@@ -49,6 +49,24 @@ const FIELDS: FieldDef[] = [
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true }, { id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' }, { id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' }, { id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
// Contest
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
// Propagation / satellite
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
// Contacted station (activation refs / SIG)
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
// Misc // Misc
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' }, { id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' }, { id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
@@ -65,11 +83,14 @@ const STATUS_VALUES: { v: string; label: string }[] = [
{ v: '_', label: 'bulk.statusBlank' }, { v: '_', label: 'bulk.statusBlank' },
]; ];
const GROUPS = ['QSL / upload', 'My station', 'Misc']; const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
// Maps the internal group key → its i18n label key. // Maps the internal group key → its i18n label key.
const GROUP_LABELS: Record<string, string> = { const GROUP_LABELS: Record<string, string> = {
'QSL / upload': 'bulk.groupQsl', 'QSL / upload': 'bulk.groupQsl',
'My station': 'bulk.groupMyStation', 'My station': 'bulk.groupMyStation',
'Contacted station': 'bulk.groupContacted',
'Contest': 'bulk.groupContest',
'Propagation': 'bulk.groupPropagation',
'Misc': 'bulk.groupMisc', 'Misc': 'bulk.groupMisc',
}; };
@@ -130,8 +151,11 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<div key={g}> <div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div> <div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
{FIELDS.filter((f) => f.group === g).map((f) => ( {FIELDS.filter((f) => f.group === g)
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem> .map((f) => ({ f, txt: t(f.label) }))
.sort((a, b) => a.txt.localeCompare(b.txt))
.map(({ f, txt }) => (
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
))} ))}
</div> </div>
))} ))}
+8 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react'; import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
@@ -134,6 +134,12 @@ interface Props {
export function FilterBuilder({ open, initial, onApply, onClose }: Props) { export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
// grouped by category for maintenance; the operator wants it A→Z to find things).
const sortedFields = useMemo(
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
[t],
);
const [conditions, setConditions] = useState<FilterCondition[]>([]); const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND'); const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({}); const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
@@ -237,7 +243,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
}}> }}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)} {sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}> <Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
+236 -63
View File
@@ -6,7 +6,9 @@ import {
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -24,7 +26,10 @@ type FlexState = {
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[]; rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number; split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
@@ -34,14 +39,16 @@ type FlexState = {
}; };
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean }; type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number }; type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
const ZERO: FlexState = { const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false, amp_available: false, amp_operate: false,
@@ -139,31 +146,93 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
); );
} }
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
// driven identically.
//
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
// flipping RIT back on returns you exactly where you were, like the radio's own
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
const OFFSET_MAX = 99999; // SmartSDR's limit
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
disabled?: boolean; title?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const step = useRef((_d: number) => {});
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
// React's onWheel is passive, so preventDefault() there is ignored and the panel
// scrolls under the cursor instead of the value changing. Bind it natively.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(e.deltaY < 0 ? mag : -mag);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const onKey = (e: React.KeyboardEvent) => {
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
if (!up && !dn) return;
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(up ? mag : -mag);
};
return (
<div className="flex items-center gap-2" title={title}>
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
'focus:outline-none focus:ring-2 focus:ring-info/50',
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground"></button>
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
{hz > 0 ? '+' : hz < 0 ? '' : ''}{Math.abs(hz || 0)} Hz
</span>
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
</div>
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi. // MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their // `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak). // 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26; const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: { function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) { }) {
const span = hi - lo; const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS); const lit = Math.round((pct / 100) * METER_SEGMENTS);
return ( return (
<div onClick={onClick} title={title} <div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0', className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}> onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className="flex items-baseline justify-between gap-1 mb-1.5"> <div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90"> <span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : ( {display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></> <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)} )}
</span> </span>
</div> </div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */} {/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]"> <div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => { {Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit; const on = i < lit;
const frac = i / METER_SEGMENTS; const frac = i / METER_SEGMENTS;
@@ -176,7 +245,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
); );
})} })}
</div> </div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>} {extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div> </div>
); );
} }
@@ -262,6 +331,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0]) ? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1; : -1;
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
// user wanted these in the header to save vertical space in the Meters card.
const hdrMeters = st.meters || [];
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const voltM = hdrFind('13.8') || hdrFind('VOLT');
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
return ( return (
<div className="h-full min-h-0 overflow-auto bg-background"> <div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3"> <div className="max-w-5xl mx-auto p-3 space-y-3">
@@ -274,6 +350,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span> <span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
{!off && (voltM || patempM) && (
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
{voltM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
</span>
)}
{patempM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
</span>
)}
</div>
)}
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider', <span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-muted text-muted-foreground' !st.available ? 'bg-muted text-muted-foreground'
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}> : st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
@@ -295,9 +388,11 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}> sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold', <span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span> sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
<span className="font-mono font-bold tabular-nums text-sm">{(sl.freq_hz / 1e6).toFixed(3)}</span> <span className="flex items-baseline gap-1.5">
<span className="text-[11px] text-muted-foreground uppercase">{sl.mode}</span> <span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
{sl.band ? <span className="text-[10px] text-muted-foreground">{sl.band}</span> : null} <span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
</span>
{/* TX badge — click a non-TX slice's badge to move TX onto it {/* TX badge — click a non-TX slice's badge to move TX onto it
(e.g. transmit on the active slice). stopPropagation so it (e.g. transmit on the active slice). stopPropagation so it
doesn't also fire the outer "make active" click. */} doesn't also fire the outer "make active" click. */}
@@ -313,6 +408,66 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div> </div>
)} )}
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
const sliceMeter = (name: string) => {
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
if (m.length === 0) return undefined;
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
};
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
// Mic input level + speech-compression (voltage & PA temp live in the
// header now). All already in the meter stream.
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
const comp = radio('COMPPEAK') || radio('COMP');
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
);
})()}
</Card>
{off && ( {off && (
<div className="text-center text-sm text-muted-foreground py-6"> <div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')} {t('flxp.waiting')}
@@ -363,6 +518,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)} )}
</div> </div>
{!isCW ? ( {!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -389,6 +545,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} /> <Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div> </div>
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
blur/Enter so we don't spam a command per keystroke; the hold guard
keeps the poll from clobbering the field mid-edit. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
{(() => {
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
return (<>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-muted-foreground text-xs"></span>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</>);
})()}
</div>
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
<select disabled={off} value={st.mic_profile || ''}
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
{!st.mic_profiles.includes(st.mic_profile || '') && <option value=""></option>}
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
</div> </div>
) : ( ) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */ /* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
@@ -436,6 +625,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div> </div>
</div> </div>
)} )}
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
listening (chasing a station that drifts off your transmit frequency),
so it belongs with the other things you touch mid-QSO. Both act on the
ACTIVE slice and follow slice focus like every control here. */}
<div className="space-y-1.5 pb-3 border-b border-border/60">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
@@ -462,6 +663,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))} onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2" <LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))} onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -558,71 +763,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span> <span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)} )}
</div> </div>
</Card> {/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
)} and shown compact so they sit with the amp controls. */}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => { {(() => {
const meters = st.meters || []; const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626' if (off || amp.length === 0) return null;
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return ( return (
<> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />; return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
} }
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />; const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})} })}
</div> </div>
</div>
)}
</>
); );
})()} })()}
</Card> </Card>
)}
</div> </div>
</div> </div>
); );
+25 -4
View File
@@ -58,6 +58,21 @@ const BANDS: { l: string; hz: number }[] = [
// SSB by frequency and the rig's data variant for digital modes. // SSB by frequency and the rig's data variant for digital modes.
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM']; const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
function attOptions(model?: string): { v: string; l: string }[] {
const m = (model ?? '').toUpperCase();
const OFF = { v: '0', l: 'OFF' };
if (/(7610|7700|7800|7850|7851)/.test(m)) {
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
}
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// fmtVFO renders a Hz frequency the way an Icom front panel does: // fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00". // MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string { function fmtVFO(hz?: number): string {
@@ -534,7 +549,7 @@ function ScopePanadapter() {
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are // Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the // read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh. // next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
const { t } = useI18n(); const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO); const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
@@ -637,8 +652,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null} {st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig {/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
on connect; ON sends the wake preamble then the rig boots ~15 s. */} unpowered while the rig is off, so power-ON can't reach it (OFF works
but ON doesn't); hiding both avoids a dead button. On the network the
rig's LAN server stays alive in standby, so both work. */}
{isNetwork && (
<>
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')} <button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20"> className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON <Power className="size-3.5" /> ON
@@ -647,6 +666,8 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20"> className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
<Power className="size-3.5" /> OFF <Power className="size-3.5" /> OFF
</button> </button>
</>
)}
</div> </div>
</div> </div>
@@ -804,7 +825,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} /> onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row> </Row>
<Row label="Att"> <Row label="Att">
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]} <Segmented value={String(st.att)} options={attOptions(cat?.rig)}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} /> onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row> </Row>
<Row label={t('icmp.filter')}> <Row label={t('icmp.filter')}>
+39 -13
View File
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light'; return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
} }
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
// down: satellite loads TWO tile layers, so its tile count — and the composited
// layers WebView2 has to hold — is double every other basemap's.
function addBasemap(
m: L.Map,
key: BasemapKey,
base: React.MutableRefObject<L.TileLayer | null>,
labels: React.MutableRefObject<L.TileLayer | null>,
) {
if (base.current) { m.removeLayer(base.current); base.current = null; }
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
const bm = BASEMAPS[key];
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
}
function dot(color: string): L.DivIcon { function dot(color: string): L.DivIcon {
return L.divIcon({ return L.divIcon({
className: '', className: '',
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// One-time map creation. // One-time map creation.
useEffect(() => { useEffect(() => {
if (worldRef.current && !worldMap.current) { if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true }) // preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1); .setView([20, 0], 1);
const bm = BASEMAPS[basemap]; addBasemap(m, basemap, baseLayer, labelsLayer);
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m); worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m; worldMap.current = m;
const sv = loadMapView(); const sv = loadMapView();
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); }); m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
} }
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80); const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t); // Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []); }, []);
// Swap the basemap (and its optional place-name overlay) when the operator // Swap the basemap (and its optional place-name overlay) when the operator
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// overlayPane, always above any tile layer, so nothing to re-stack there. // overlayPane, always above any tile layer, so nothing to re-stack there.
useEffect(() => { useEffect(() => {
const m = worldMap.current; const m = worldMap.current;
if (!m) return; if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
}, [basemap]); }, [basemap]);
// Redraw overlays whenever the operator/DX grids (or beam) change. // Redraw overlays whenever the operator/DX grids (or beam) change.
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
} }
setTimeout(() => { wm.invalidateSize(); }, 0); // No invalidateSize() here — a ResizeObserver handles the only case that needs
// it. Forcing a full map repaint on every redraw is what made a moving rotor
// thrash the compositor.
// Headings are rounded in the deps: a rotor reports a jittering float, and a
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid); const path = pathBetween(fromGrid, toGrid);
+92 -1
View File
@@ -5,7 +5,7 @@ import {
} from 'ag-grid-community'; } from 'ag-grid-community';
import { hamlogGridTheme } from '@/lib/gridTheme'; import { hamlogGridTheme } from '@/lib/gridTheme';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react'; import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -20,6 +20,7 @@ import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID, NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup, NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive, NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
WorkedBefore,
} from '@/../wailsjs/go/main/App'; } from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models'; import { netctl } from '@/../wailsjs/go/models';
@@ -67,6 +68,36 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const activeGrid = useRef<any>(null); const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null); const rosterGrid = useRef<any>(null);
// Worked-before for the clicked station (see if/when we contacted it before).
const [wbCall, setWbCall] = useState('');
const [wb, setWb] = useState<any>(null);
const [wbBusy, setWbBusy] = useState(false);
const wbReq = useRef(0);
// Resizable height (drag the handle on top). Persisted.
const [wbHeight, setWbHeight] = useState(() => {
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
return v >= 80 && v <= 600 ? v : 176;
});
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
const startWbResize = (e: React.MouseEvent) => {
e.preventDefault();
const startY = e.clientY, startH = wbHeight;
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
const c = (call ?? '').trim().toUpperCase();
setWbCall(c);
if (!c) { setWb(null); return; }
const req = ++wbReq.current;
setWbBusy(true);
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
catch { if (wbReq.current === req) setWb(null); }
finally { if (wbReq.current === req) setWbBusy(false); }
}, []);
const isOpen = openId !== '' && openId === selId; const isOpen = openId !== '' && openId === selId;
const refreshNets = useCallback(async () => { const refreshNets = useCallback(async () => {
@@ -173,6 +204,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
...c, callsign: (r.callsign || call).toUpperCase(), ...c, callsign: (r.callsign || call).toUpperCase(),
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country, name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq, dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
email: r.email || c.email,
})); }));
} catch (e: any) { setError(String(e?.message ?? e)); } } catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLooking(false); } finally { setLooking(false); }
@@ -266,6 +300,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
rowData={active} rowData={active}
columnDefs={activeCols} columnDefs={activeCols}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)} onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }} rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false} animateRows={false}
@@ -281,6 +316,61 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
</Button> </Button>
</div> </div>
)} )}
{/* Worked-before for the clicked station — click a row (on air or
roster) to see if/when we contacted it before. Lives INSIDE the
on-air column so resizing only splits this column; the roster on
the right keeps its full height. */}
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
<div className="flex-1 min-h-0 bg-card flex flex-col">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
<History className="size-3.5" />
{t('ncp.workedBefore')}
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
{wb && wb.count > 0 && (
<span className="ml-auto font-normal normal-case text-foreground">
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
</span>
)}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{!wbCall ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
) : wbBusy ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground"></div>
) : !wb || wb.count === 0 ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
<tr className="text-left">
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
<th className="px-2 py-1 font-semibold">RST</th>
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
</tr>
</thead>
<tbody>
{((wb.entries ?? []) as any[]).map((q, i) => (
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
<td className="px-2 py-1">{q.band}</td>
<td className="px-2 py-1">{q.mode}</td>
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div> </div>
{/* NET USERS / roster (right) */} {/* NET USERS / roster (right) */}
@@ -298,6 +388,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
columnDefs={rosterCols} columnDefs={rosterCols}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)} onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false} animateRows={false}
getRowId={(p) => String((p.data as any).callsign)} getRowId={(p) => String((p.data as any).callsign)}
+30 -10
View File
@@ -80,11 +80,23 @@ function StatusCell({ value }: { value?: string }) {
if (v === '') { if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>; return <span className="block text-center text-[11px] text-muted-foreground"></span>;
} }
// One colour per state, and each colour means something:
// Yes green — confirmed, the thing you wanted
// Requested blue — in flight, waiting on the other end. Not a problem.
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
// This is the ONLY state asking for action, so it gets the
// only alarming colour.
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
// every row; painting that orange (as it used to be, in the
// same orange as Requested) made the table shout about a
// non-problem and told you nothing apart.
// Ignore dashed — deliberately excluded, on purpose.
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo'); const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' ? 'bg-success text-success-foreground' const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
: v === 'R' ? 'bg-warning text-warning-foreground' : v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
: v === 'I' ? 'bg-muted-foreground text-background' : v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
: 'bg-warning text-warning-foreground'; : v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
: 'bg-muted text-muted-foreground border border-border';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>; return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
} }
@@ -187,6 +199,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
// WPX, …) are derived from the QSO by the backend and shown read-only. // WPX, …) are derived from the QSO by the backend and shown read-only.
const awardFieldRef = useRef<Record<string, string>>({}); const awardFieldRef = useRef<Record<string, string>>({});
const [awardRefs, setAwardRefs] = useState(''); const [awardRefs, setAwardRefs] = useState('');
// The refs present when the editor opened, so on save we can tell which ones
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
const seedAwardRefsRef = useRef('');
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]); const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
// Load award definitions once, then seed the editable manual refs from the QSO. // Load award definitions once, then seed the editable manual refs from the QSO.
@@ -207,6 +222,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
.filter((r: any) => r.pickable) .filter((r: any) => r.pickable)
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`) .map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
.join(';'); .join(';');
seedAwardRefsRef.current = seed;
setAwardRefs(seed); setAwardRefs(seed);
} catch { /* leave manual refs empty on failure */ } } catch { /* leave manual refs empty on failure */ }
}) })
@@ -327,7 +343,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
// the dedicated columns, then route the picked refs back onto the payload // the dedicated columns, then route the picked refs back onto the payload
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras). // (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = ''; out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current); applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
onSave(out); onSave(out);
} }
@@ -347,7 +363,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2"> <DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle> <DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span> <span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
@@ -567,9 +583,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</p> </p>
</div> </div>
{/* Right: live status grid for every channel */} {/* Right: live status grid for every channel.
<div className="w-72 shrink-0"> Sized by its content, not pinned to a width: a fixed 288px box
<table className="w-full border-separate" style={{ borderSpacing: 4 }}> left the label column too narrow, so "QSL (paper)" wrapped onto
two lines and padded out the whole row. There is spare width to
the right — spend it on the label. */}
<div className="shrink-0">
<table className="border-separate" style={{ borderSpacing: 4 }}>
<thead> <thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground"> <tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">{t('qedit.thType')}</th> <th className="text-left font-semibold">{t('qedit.thType')}</th>
@@ -580,7 +600,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<tbody> <tbody>
{CONFIRMATIONS.map((c) => ( {CONFIRMATIONS.map((c) => (
<tr key={c.key} className="text-xs"> <tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td> <td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td> <td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr> </tr>
@@ -274,6 +274,12 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
headerTooltip: t('rqg.awardTip', { name: a.name }), headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110, width: 110,
cellClass: 'text-[11px]', cellClass: 'text-[11px]',
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
// them whenever the saved column state doesn't cover them — a newly-added
// award, an empty cache, or a rebuild that runs before the saved state is
// re-applied — which is why "all award columns" kept reappearing. The user's
// saved state (a column they explicitly showed) still overrides this.
hide: true,
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
})); }));
return [...base, ...awards]; return [...base, ...awards];
+211 -11
View File
@@ -21,6 +21,8 @@ import {
GetEmailSettings, SaveEmailSettings, TestEmail, GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates, QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
AudioStartTX, AudioStopTX, AudioTXActive,
ListClusterServers, SaveClusterServer, DeleteClusterServer, ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect, GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
@@ -35,6 +37,7 @@ import {
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
@@ -258,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'PstRotator', rotator: 'PstRotator',
winkeyer: 'CW Keyer', winkeyer: 'CW Keyer',
antenna: 'UltraBeam', antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'Power Genius', pgxl: 'Power Genius',
flex: 'FlexRadio', flex: 'FlexRadio',
@@ -341,6 +344,9 @@ function TreeNodeView({
// Select and slamming the open dropdown shut on any ambient re-render. // Select and slamming the open dropdown shut on any ambient re-render.
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = { const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' }, 'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' }, 'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' }, 'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' }, 'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
@@ -759,6 +765,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
); );
} }
// Known Icom models paired with their FACTORY default CI-V address. Picking a
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 },
{ name: 'IC-7800', addr: 0x80 },
{ name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 },
];
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
@@ -795,8 +815,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState(''); const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
@@ -806,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings. // Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({ const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({
enabled: false, host: '', port: 23, follow: false, step_khz: 50, enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
}); });
const [ubTesting, setUbTesting] = useState(false); const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -863,6 +883,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 }); const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState(''); const [dvkErr, setDvkErr] = useState('');
const [monitorOn, setMonitorOn] = useState(false);
const [txOn, setTxOn] = useState(false);
useEffect(() => {
AudioMonitorActive().then(setMonitorOn).catch(() => {});
AudioTXActive().then(setTxOn).catch(() => {});
}, []);
const toggleMonitor = async () => {
try {
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
else { await AudioStartMonitor(); setMonitorOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
const toggleTX = async () => {
try {
if (txOn) { await AudioStopTX(); setTxOn(false); }
else { await AudioStartTX(); setTxOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
// General behaviour prefs (mirrored to the DB so they travel with data/). // General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
@@ -962,6 +1000,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [clublogTesting, setClublogTesting] = useState(false); const [clublogTesting, setClublogTesting] = useState(false);
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null); const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [lotwTesting, setLotwTesting] = useState(false); const [lotwTesting, setLotwTesting] = useState(false);
// LoTW user-activity list (for the callsign badge): count + last refresh.
const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
const downloadLotwUsers = async () => {
setLotwUsersBusy(true);
try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
finally { setLotwUsersBusy(false); }
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false); const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -2007,6 +2055,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} /> <Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span> {t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
</label> </label>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
</label>
{catCfg.flex_decode_spots && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
<Input type="number" className="w-24" min={10} max={3600}
value={catCfg.flex_decode_secs || 120}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
</> </>
)} )}
{catCfg.backend === 'icom' && ( {catCfg.backend === 'icom' && (
@@ -2034,12 +2095,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1 col-span-2"> <div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('cat.civAddr')}</Label> <Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')} <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} /> onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
<p className="text-xs text-muted-foreground">{t('cat.civHint')}</p>
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
</> </>
)} )}
{catCfg.backend === 'icom-net' && ( {catCfg.backend === 'icom-net' && (
@@ -2049,6 +2122,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''} <Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} /> onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
</div> </div>
<div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.civAddr')}</Label> <Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')} <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
@@ -2065,6 +2150,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} /> onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p> <p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={!!(catCfg as any).icom_net_audio}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
<span>
{t('cat.icomNetAudio')}
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
</span>
</label>
</> </>
)} )}
{catCfg.backend === 'tci' && ( {catCfg.backend === 'tci' && (
@@ -2174,16 +2267,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
function UltrabeamPanel() { function UltrabeamPanel() {
const isSteppir = ultrabeam.type === 'steppir';
const isSerial = isSteppir && ultrabeam.transport === 'serial';
return ( return (
<> <>
<SectionHeader <SectionHeader
title={t('hw.ultrabeam')} title={t('hw.motorAntenna')}
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
Enable Ultrabeam control {t('hw.motorEnable')}
</label> </label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>{t('hw.motorType')}</Label>
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
so the serial fields never apply to it. */}
<Select value={ultrabeam.type ?? 'ultrabeam'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
<SelectItem value="steppir">SteppIR</SelectItem>
</SelectContent>
</Select>
</div>
{isSteppir && (
<div className="space-y-1">
<Label>{t('hw.motorTransport')}</Label>
<Select value={ultrabeam.transport ?? 'tcp'}
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>{t('hw.motorCom')}</Label>
<Input
value={ultrabeam.com ?? ''}
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
placeholder="COM3"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>{t('hw.motorBaud')}</Label>
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host / IP</Label> <Label>Host / IP</Label>
@@ -2204,6 +2349,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
/> />
</div> </div>
</div> </div>
)}
{isSteppir && (
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
)}
<div className="border-t border-border/60 pt-3 space-y-2"> <div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} /> <Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
@@ -2225,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} )}
</div> </div>
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}> <Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
{ubTesting ? t('hw.connecting') : t('hw.testConn')} {ubTesting ? t('hw.connecting') : t('hw.testConn')}
</Button> </Button>
</div> </div>
@@ -3485,6 +3634,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} )}
</div> </div>
</div> </div>
{/* LoTW user list — powers the colour-coded "LoTW" badge next to the
entered callsign (green < 1 week · amber 14 weeks · red > 30 days). */}
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
</Button>
<span className="text-xs text-muted-foreground">
{lotwUsers.count > 0
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
: t('lotw.usersNone')}
</span>
</div>
</div>
</div> </div>
) : extSvcTab === 'pota' ? ( ) : extSvcTab === 'pota' ? (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
@@ -3747,6 +3913,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '} <strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted. <strong>To Radio</strong> = where voice-keyer messages are transmitted.
</p> </p>
<div className="flex items-center gap-3">
<Button
variant={monitorOn ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio}
title="Hear the rig's RX audio (From Radio) through your Listening device"
>
{monitorOn ? ' Stop listening' : ' Listen to radio'}
</Button>
<span className="text-[11px] text-muted-foreground">
{monitorOn
? 'RX monitor running From Radio Listening device.'
: 'Live-monitor the rig here (USB codec now; network audio later).'}
</span>
</div>
<div className="flex items-center gap-3">
<Button
variant={txOn ? 'destructive' : 'outline'}
size="sm"
className="h-8"
onClick={toggleTX}
disabled={!txOn && !audioCfg.to_radio}
title="Key PTT and pipe your live mic into the rig (To Radio device)"
>
{txOn ? ' Stop talking (TX)' : '🎙 Talk to radio (TX)'}
</Button>
<span className="text-[11px] text-muted-foreground">
{txOn
? 'TRANSMITTING mic To Radio, PTT keyed. Click to stop.'
: 'Live mic rig with PTT (USB now; network TX later).'}
</span>
</div>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl"> <div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
+752
View File
@@ -0,0 +1,752 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
// ─────────────────────────────────────────────────────────────────────────────
// Statistics dashboard.
//
// Colour rules this file obeys (they are not stylistic — they are what keeps the
// charts readable and colour-blind-safe):
//
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
// encodes identity, never a magnitude the bar's length already shows.
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
// the categories ARE the subject (the continent donut). The order is the
// colour-blind-safety mechanism, so it is never shuffled or cycled.
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
// makes them interesting. Close values belong in bars.
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
// a table view gives the WCAG-clean twin.
// ─────────────────────────────────────────────────────────────────────────────
type Bucket = { key: string; count: number };
type Gap = { start: string; end: string; minutes: number };
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
type Stats = {
total: number; unique_calls: number; entities: number; continents: number;
first_qso: string; last_qso: string;
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
on_air_minutes: number; off_air_minutes: number;
peak_hour_key: string; peak_hour_count: number; best_60: number;
gaps: Gap[]; rate: Bucket[];
rate_ops: string[]; rate_by_op: number[][];
};
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
const CONTINENT_NAME: Record<string, string> = {
EU: 'Europe', NA: 'North America', SA: 'South America',
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
};
const nf = (n: number) => n.toLocaleString('en-US');
// ── Shell ────────────────────────────────────────────────────────────────────
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
<header className="mb-3 shrink-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
</header>
{children}
</section>
);
}
// A headline number IS the chart — a one-bar bar chart would be noise.
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
</div>
);
}
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
// One series → one hue. The value is direct-labelled, so no reader ever depends
// on a tooltip to get a number.
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
// max is a display cap for long tails (top entities). Where EVERY row matters —
// the operators of a multi-op — it is deliberately not set: a capped chart would
// silently drop the 9th operator, and "who worked what" is the whole question.
const top = max ? data.slice(0, max) : data;
const peak = Math.max(1, ...top.map((d) => d.count));
const total = data.reduce((s, d) => s + d.count, 0);
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
return (
<div className="flex flex-col gap-1.5 min-w-0">
{top.map((d) => (
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key}${nf(d.count)}`}>
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
<div className="flex-1 min-w-0 h-[14px] flex items-center">
<div
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
/>
</div>
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
{share && (
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
</span>
)}
</div>
))}
</div>
);
}
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
// information.
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
const peak = Math.max(1, ...data.map((d) => d.count));
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
// (the value is in the tooltip, and every number is in the table view).
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
return (
// The container includes the x-axis band — a fixed height that excludes it is
// how cards end up with a tiny nested scrollbar.
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
{data.map((d, i) => (
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
title={`${d.key}${nf(d.count)}`}>
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
{nf(d.count)}
</span>
<div
className="w-full rounded-t-[4px] transition-[height] duration-300"
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
/>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
{i % every === 0 ? d.key : ''}
</span>
</div>
))}
</div>
);
}
// ── Contest rate, stacked by operator ────────────────────────────────────────
// The categories (the operators) ARE the subject, so this is categorical colour,
// in the FIXED order the backend sends (busiest first). An operator therefore
// keeps the same hue everywhere on the page — a chart that repaints its series
// when the filter changes is a chart nobody can trust.
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
}) {
const [hov, setHov] = useState<number | null>(null);
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const peak = Math.max(1, ...rate.map((d) => d.count));
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
return (
<div className="min-w-0">
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
{rate.map((d, i) => (
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
{/* The column is the hour's total; the segments are who made it. */}
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
{(byOp[i] ?? []).map((n, o) => n > 0 && (
<div key={o} style={{
height: `${(n / Math.max(1, d.count)) * 100}%`,
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
}} />
))}
</div>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
{i % every === 0 ? d.key : ''}
</span>
</div>
))}
</div>
{/* Hover read-out: the hour, its total, and the split. Values are also in the
rate sheet below, so nothing is reachable by hover alone. */}
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
{hov !== null && (
<>
<span className="text-foreground font-medium">{rate[hov].key}</span>
{' · '}{nf(rate[hov].count)} QSO
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}></span> {ops[o]} {n}</span>
))}
</>
)}
</p>
{!single && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
{ops.map((op, o) => (
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</span>
))}
</div>
)}
</div>
);
}
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
// read rate sheets as tables, and every value here is also the one the chart draws.
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
if (rate.length === 0) return null;
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
const grand = rate.reduce((s, d) => s + d.count, 0);
return (
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
<table className="w-full text-[11px] tabular-nums">
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
<tr className="text-muted-foreground">
<th className="text-left font-medium px-2 py-1">UTC</th>
{ops.map((op, o) => (
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
{op}
</th>
))}
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
</tr>
</thead>
<tbody>
{shown.map(({ d, i }) => (
<tr key={d.key} className="border-t border-border/50">
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
{ops.map((_, o) => (
<td key={o} className="px-2 py-0.5 text-right">
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
</td>
))}
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
</tr>
))}
</tbody>
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
<tr className="border-t border-border font-semibold">
<td className="px-2 py-1">Total</td>
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
<td className="px-2 py-1 text-right">{nf(grand)}</td>
</tr>
</tfoot>
</table>
</div>
);
}
// ── Activity over time (single series → area, one hue) ────────────────────────
// Only the PEAK and the LAST point are direct-labelled. A number on every point
// is chaos and goes unread.
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
const [hover, setHover] = useState<number | null>(null);
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
const x = (i: number) => (i / (data.length - 1)) * W;
const y = (v: number) => H - (v / peak) * H;
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
const area = `${line} L${W},${H} L0,${H} Z`;
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
const h = hover ?? -1;
return (
<div className="min-w-0">
<div className="relative" style={{ height }}>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
onMouseLeave={() => setHover(null)}>
{/* Recessive hairline grid — solid, never dashed. */}
{[0, 0.5, 1].map((f) => (
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
<defs>
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
</linearGradient>
</defs>
<path d={area} fill="url(#statsArea)" />
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
strokeLinejoin="round" strokeLinecap="round" />
{h >= 0 && (
<>
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
{/* 2px surface ring so the marker reads on top of the area. */}
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
</>
)}
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
{data.map((_, i) => (
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
fill="transparent" onMouseEnter={() => setHover(i)} />
))}
</svg>
{h >= 0 && (
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
<span className="text-muted-foreground">{data[h].key}</span>{' '}
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
</div>
)}
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
<span>{data[0].key}</span>
<span className="text-foreground font-medium"> {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
<span>{data[data.length - 1].key}</span>
</div>
</div>
);
}
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
// few segments, one share obviously dominant. It is the WRONG form for comparing
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
// donut would hide precisely the fact that makes them interesting).
//
// The categories ARE the subject here, so this is the one chart using the
// categorical slots — in FIXED order, with a legend and printed values, because
// the dark palette sits in the CVD floor band where labels are mandatory.
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
const [hov, setHov] = useState<number | null>(null);
const total = data.reduce((s, d) => s + d.count, 0);
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const slots = data.slice(0, 8); // never generate a 9th hue
const R = 58, SW = 22, C = 2 * Math.PI * R;
let acc = 0;
const arcs = slots.map((d, i) => {
const frac = d.count / total;
// A 2px surface gap between segments — the correct separator, not a border.
const len = Math.max(0, frac * C - 2);
const a = { key: d.key, i, len, off: -acc * C, frac };
acc += frac;
return a;
});
return (
<div className="flex items-center gap-4 min-w-0">
<div className="relative shrink-0">
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
{arcs.map((a) => (
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
className="transition-[stroke-width] duration-150 cursor-default"
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
))}
</svg>
{/* The hole is not decoration — it carries the total the slices add up to. */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
{hov === null ? (
<>
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
</>
) : (
<>
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
</span>
</>
)}
</div>
</div>
<ul className="flex-1 min-w-0 flex flex-col gap-1">
{slots.map((d, i) => (
<li key={d.key}
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
hov === i && 'bg-muted')}>
{/* The swatch carries identity; the text stays in ink tokens. */}
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
{((d.count / total) * 100).toFixed(0)}%
</span>
</li>
))}
</ul>
</div>
);
}
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2 mb-1">
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
<span className="text-[11px] tabular-nums">
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
<span className="text-muted-foreground"> · {nf(value)}</span>
</span>
</div>
{/* Same-ramp track: the meter and its track are one hue, not two. */}
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
<div className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
</div>
</div>
);
}
// ── Period ───────────────────────────────────────────────────────────────────
// ONE filter row above everything it scopes — never a control inside a card, or
// the charts would each show a different slice of time.
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
const iso = (d: Date) => d.toISOString().slice(0, 10);
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
function periodRange(p: Period, from: string, to: string): [string, string] {
const now = new Date();
switch (p) {
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
case 'y12': {
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
return [iso(d), ''];
}
case 'd30': {
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
return [iso(d), ''];
}
case 'custom': return [from, to];
default: return ['', ''];
}
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
const total = data.reduce((s, d) => s + d.count, 0);
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
<table className="w-full text-[11px]">
<tbody>
{data.map((d) => (
<tr key={d.key} className="border-b border-border/50 last:border-0">
<td className="px-3 py-1 truncate">{d.key}</td>
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
</td>
</tr>
))}
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic"></td></tr>}
</tbody>
</table>
</div>
);
}
// ── Panel ────────────────────────────────────────────────────────────────────
export function StatsPanel() {
const { t } = useI18n();
const [stats, setStats] = useState<Stats | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [view, setView] = useState<'charts' | 'table'>('charts');
const [period, setPeriod] = useState<Period>('all');
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
// that contest AND lets the window derive from its own span — no date typing.
const [runs, setRuns] = useState<ContestRun[]>([]);
const [contest, setContest] = useState('');
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
// A contest defines its own window (its first→last QSO), so we send no dates
// with it — the backend derives them. Sending a period as well would be two
// filters fighting over the same axis.
const [cid, cyr] = c ? c.split('|') : ['', '0'];
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
setBusy(true); setErr('');
try {
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
// .length on null unmounts the whole React tree — a white screen. Normalise
// once, here, rather than guarding at every use site and missing one.
const arr = (v: any) => (Array.isArray(v) ? v : []);
setStats({
...raw,
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
}
catch (e: any) { setErr(String(e?.message ?? e)); }
finally { setBusy(false); }
};
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
// are set — otherwise every keystroke in the date box would re-scan the log.
useEffect(() => {
if (!contest && period === 'custom' && !(from && to)) return;
load(period, from, to, contest);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [period, from, to, contest]);
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
// between two ordinary evenings — numbers that are true and completely useless.
// So: show it for a picked contest, or for a hand-set range short enough to be a
// real operating session (which is exactly when an hourly rate chart exists).
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
const span = useMemo(() => {
if (!stats?.first_qso) return '';
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
return `${f.toISOString().slice(0, 10)}${l.toISOString().slice(0, 10)}`;
}, [stats]);
if (busy && !stats) {
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
</div>;
}
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
if (!stats) return null;
const empty = t('stats.noData');
return (
<div className="flex-1 min-h-0 overflow-auto p-3">
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
<div className="flex items-center flex-wrap gap-2 mb-3">
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
never from a static list — so it can't offer a contest you never entered.
Choosing one supersedes the period: it brings its own window. */}
<select value={contest} onChange={(e) => setContest(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
<option value="">{t('stats.noContest')}</option>
{runs.map((r) => (
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
{r.id} {r.year} {nf(r.count)} QSO
</option>
))}
</select>
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
{([
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
] as [Period, string][]).map(([p, label], i) => (
<button key={p} onClick={() => setPeriod(p)}
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
{label}
</button>
))}
</div>
{!contest && period === 'custom' && (
<div className="inline-flex items-center gap-1">
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
<span className="text-xs text-muted-foreground"></span>
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
</div>
)}
<div className="flex-1" />
<div className="inline-flex rounded-md border border-border overflow-hidden">
<button onClick={() => setView('charts')}
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
<BarChart3 className="size-3.5" />{t('stats.charts')}
</button>
<button onClick={() => setView('table')}
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
<Table2 className="size-3.5" />{t('stats.table')}
</button>
</div>
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
</button>
</div>
{/* Headline figures: stat tiles, not a grouped bar chart. */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
</div>
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
seventeen years is noise; across a contest weekend it IS the result. */}
{windowed && stats.total > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
<div title={t('stats.avgWindowTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
</div>
<div title={t('stats.avgActiveTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
</div>
<div title={t('stats.best60Tip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
</div>
{/* On-air + off-air = the window, by construction. The first version
counted "clock hours containing a QSO", which gave 39 h on air AND
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
<div title={t('stats.onAirTip')}>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
<p className="text-[22px] leading-none font-semibold mt-1">
{dur(stats.on_air_minutes)}
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
</p>
</div>
</div>
{stats.rate.length > 1
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
</Card>
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
{stats.gaps.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
) : (
<ul className="flex flex-col gap-1 min-w-0">
{stats.gaps.map((g, i) => (
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
<span className="tabular-nums text-muted-foreground truncate">
{g.start.slice(5, 16).replace('T', ' ')} {g.end.slice(11, 16)}
</span>
<span className="flex-1 h-[6px] rounded-full min-w-0"
style={{
background: 'var(--chart-seq-1)',
}}>
<span className="block h-full rounded-full"
style={{
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
background: 'var(--chart-1)',
}} />
</span>
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
</li>
))}
</ul>
)}
</Card>
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
by operator. That is a table's job, not a chart's — and it's how
contesters actually read a run. Every value here is also what the
stacked chart above draws, so the two can never disagree. */}
{stats.rate.length > 1 && (
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
</Card>
)}
</div>
)}
{view === 'table' ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
<VBars data={stats.by_band} empty={empty} />
</Card>
<Card title={t('stats.byMode')}>
<HBars data={stats.by_mode} max={8} empty={empty} />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
<AreaTrend data={stats.by_month} empty={empty} />
</Card>
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
instead of truncating. */}
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_operator} empty={empty} share />
</div>
</Card>
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
<Donut data={stats.by_continent} empty={empty} />
</Card>
<Card title={t('stats.topEntities')}>
<HBars data={stats.top_entities} max={12} empty={empty} />
</Card>
<div className="flex flex-col gap-2.5 min-w-0">
<Card title={t('stats.confirmations')} className="flex-1">
<div className="flex flex-col gap-3 justify-center flex-1">
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
</div>
</Card>
<Card title={t('stats.byStation')}>
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
<HBars data={stats.by_station} empty={empty} share />
</div>
</Card>
</div>
</div>
)}
</div>
);
}
@@ -321,7 +321,6 @@ function EditDialog({
<Label>{t('udpp.name')}</Label> <Label>{t('udpp.name')}</Label>
<Input <Input
autoFocus autoFocus
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
value={draft.name} value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))} onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
/> />
+5 -4
View File
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
// QSOBoxView renders the confirmation box with per-QSO values. // QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) { function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
const avail = box.w - 56; const avail = box.w - 56;
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1; const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
let cursor = 28; let cursor = 28;
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
return ( return (
<> <>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} /> <rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d" <text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge"> fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title} {box.title}
</text> </text>
{cols.map(({ f, x }) => ( {cols.map(({ f, x }) => (
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}> <g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5} <text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge"> fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()} {(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text> </text>
<text y={26} fontSize={28} fontWeight={700} fill="#14243a" <text y={26} fontSize={28} fontWeight={700} fill={fg}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge"> fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''} {values[f] ?? ''}
</text> </text>
</g> </g>
))} ))}
{box.footer && ( {box.footer && (
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63" <text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
fontFamily="'Segoe UI', Arial, sans-serif"> fontFamily="'Segoe UI', Arial, sans-serif">
{box.footer} {box.footer}
</text> </text>
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
onChange={(w) => onPatchBox({ w })} /> onChange={(w) => onPatchBox({ w })} />
<NumberField label="Height" value={box.h} min={120} max={500} <NumberField label="Height" value={box.h} min={120} max={500}
onChange={(h) => onPatchBox({ h })} /> onChange={(h) => onPatchBox({ h })} />
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Background</Label>
<div className="flex items-center gap-2">
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
</div>
</div>
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Text color</Label>
<div className="flex items-center gap-2">
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
</div>
</div>
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
onChange={(radius) => onPatchBox({ radius })} />
<div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Title</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
</div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Label className="text-xs text-muted-foreground">Footer</Label> <Label className="text-xs text-muted-foreground">Footer</Label>
<Input className="h-7 w-44 font-mono text-xs" value={box.footer} <Input className="h-7 w-44 font-mono text-xs" value={box.footer}
+1
View File
@@ -118,6 +118,7 @@ export interface QSOBox {
h: number; h: number;
bg: string; bg: string;
bg_opacity: number; bg_opacity: number;
fg?: string; // text colour (default dark); set it when using a dark background
radius: number; radius: number;
title: string; title: string;
fields: string[]; fields: string[];
+30 -1
View File
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out; return out;
} }
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
// text field — the inverse of appendTokens. Used when a picked reference is
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
// otherwise the ref is re-derived from the field and reappears on reopen.
function removeTokens(existing: string | undefined, refs: string): string {
let out = existing ?? '';
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
out = out.replace(re, ' ');
}
return out.replace(/\s{2,}/g, ' ').trim();
}
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding // MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF". // the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a // The award engine honours these regardless of how the award matches, so a
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// data lives in its conventional place and exports correctly. Awards that match // data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely // a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code. // on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) { export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
const byCode = parseAwardRefs(awardRefs); const byCode = parseAwardRefs(awardRefs);
// References the operator REMOVED since the editor opened: for in-field awards
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
// ref doesn't get re-derived from the leftover token and reappear on reopen.
if (prevAwardRefs != null) {
const prev = parseAwardRefs(prevAwardRefs);
for (const [code, prevRef] of Object.entries(prev)) {
const field = fieldOf[code] || code.toLowerCase();
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
if (removed.length === 0) continue;
const rm = removed.join(',');
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
else payload.notes = removeTokens(payload.notes, rm);
}
}
const extras: Record<string, string> = { ...(payload.extras ?? {}) }; const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = []; const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) { for (const [code, ref] of Object.entries(byCode)) {
+146 -22
View File
@@ -13,6 +13,8 @@ type Dict = Record<string, string>;
const en: Dict = { const en: Dict = {
// Menu bar // Menu bar
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…', 'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit', 'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
@@ -21,6 +23,7 @@ const en: Dict = {
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…', 'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)', 'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode', 'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…', 'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
// Duplicates modal // Duplicates modal
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉', 'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
@@ -36,15 +39,49 @@ const en: Dict = {
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid', 'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment', 'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx', 'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'TX Freq (MHz)', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band', 'field.txFreq': 'TX Freq (MHz)', 'field.freqTuneHint': 'Type a frequency and press Enter to tune the radio here.', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv', 'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…', 'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser // Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.', 'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.', 'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
'stats.byContinentSub': 'Share of the log',
'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom',
'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included',
'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)',
'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.',
'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.',
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
'msg.expand': 'Click to read the full message',
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
'offline.title': 'Offline — {n} QSO(s) waiting',
'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.",
'offline.empty': 'Nothing waiting.',
'offline.retry': 'Retry now',
'offline.synced': '{n} QSO(s) added to the logbook',
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.', 'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark', 'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast', 'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
// Settings navigation (sidebar groups + section names) // Settings navigation (sidebar groups + section names)
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
@@ -54,7 +91,7 @@ const en: Dict = {
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
// General panel // General panel
'gen.hint': 'App behaviour (saved instantly).', 'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations', 'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
@@ -128,13 +165,15 @@ const en: Dict = {
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.', 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).', 'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body // CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.', 'cat.icomNetAudioHint': 'Play the rigs received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)", 'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)', 'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).', 'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
@@ -143,6 +182,7 @@ const en: Dict = {
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.', 'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
// External services (repeated labels) // External services (repeated labels)
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (12 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign', 'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (12 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
'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.',
'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',
@@ -190,16 +230,17 @@ const en: Dict = {
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal', 'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com', 'qrz.openTitle': 'Open {call} on QRZ.com',
// Misc panels/modals (alerts / send-spot / net / udp / filter / details) // Misc panels/modals (alerts / send-spot / net / udp / filter / details)
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close', 'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot', 'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
@@ -207,10 +248,31 @@ const en: Dict = {
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.', 'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.', 'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.', 'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference', 'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
'awed.exportOne': 'Share {code}',
'awed.onlyHere': 'local',
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
'awed.builtin': 'Built-in',
'awed.awardsFolder': 'Awards folder',
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
'awed.protectedFlag': 'Protected',
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
'awed.exportOneTitle': 'Export THIS award on its own (definition + references + their regexes) — the file you send someone',
'awed.importCollisionTitle': 'Some of these awards already exist',
'awed.importCollisionHint': 'Nothing is replaced unless you say so. Import as a copy installs theirs alongside yours, so you can compare before deleting one.',
'awed.importRefs': '{n} reference(s)',
'awed.importNew': 'New — will be added.',
'awed.importExists': 'You already have "{name}" with {n} reference(s).',
'awed.importProtected': 'built-in',
'awed.importKeepMine': 'Keep mine',
'awed.importReplace': 'Replace mine',
'awed.importCopy': 'Import as {code}-2',
// QSO modals (context menu / bulk edit / QSL manager / QSO edit) // QSO modals (context menu / bulk edit / QSL manager / QSO edit)
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…', 'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}', 'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}', 'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…', 'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
// Grids column headers (recent / worked-before / cluster) // Grids column headers (recent / worked-before / cluster)
@@ -220,6 +282,8 @@ const en: Dict = {
}; };
const fr: Dict = { const fr: Dict = {
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…', 'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter', 'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
@@ -228,6 +292,7 @@ const fr: Dict = {
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…', 'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)', 'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest', 'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…', 'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉', 'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.', 'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
@@ -240,14 +305,48 @@ const fr: Dict = {
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator', 'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire', 'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx', 'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
'field.txFreq': 'Fréq TX (MHz)', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX', 'field.txFreq': 'Fréq TX (MHz)', 'field.freqTuneHint': 'Tape une fréquence et appuie sur Entrée pour y accorder la radio.', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç', 'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…', 'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.', 'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français', 'lang.english': 'English', 'lang.french': 'Français',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.", 'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas dOPERATOR renseigné)',
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
'stats.byContinentSub': 'Part du journal',
'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé',
'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris',
'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)',
'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.',
'stats.avgActive': 'Moy. / heure en lair', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, cest vendre 8 h deffort comme un score de 48 h.',
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
'stats.activeHours': 'Temps en lair', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en lair + hors antenne = la fenêtre, par construction.',
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
'cluster.consoleEmpty': 'Le trafic brut du cluster saffiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
'msg.expand': 'Cliquer pour lire le message en entier',
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
'offline.tip': '{n} QSO en attente — la base est injoignable',
'offline.title': 'Hors ligne — {n} QSO en attente',
'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.",
'offline.empty': 'Rien en attente.',
'offline.retry': 'Réessayer maintenant',
'offline.synced': '{n} QSO ajoutés au journal',
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.", 'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud', 'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé', 'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
@@ -256,7 +355,7 @@ const fr: Dict = {
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).', 'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues', 'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale', 'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
@@ -321,12 +420,14 @@ const fr: Dict = {
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).", 'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 12 min pour corriger un QSO mal saisi avant).",
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.', 'cat.icomNetAudioHint': 'Écoute laudio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer ladresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)", 'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)', 'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).", 'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
@@ -334,6 +435,7 @@ const fr: Dict = {
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.", 'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.", 'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (12 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet', 'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (12 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
'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.',
'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',
@@ -376,24 +478,46 @@ const fr: Dict = {
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot', 'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.', 'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.', 'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence', 'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
'awed.exportOne': 'Partager {code}',
'awed.onlyHere': 'local',
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
'awed.builtin': 'Intégré',
'awed.awardsFolder': 'Dossier awards',
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez lutilisateur — alors que tu las livré.',
'awed.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
'awed.exportOneTitle': 'Exporter CE diplôme seul (définition + références + leurs regex) — le fichier que tu envoies à quelquun',
'awed.importCollisionTitle': 'Certains de ces diplômes existent déjà',
'awed.importCollisionHint': 'Rien nest remplacé sans ton accord. « Importer en copie » installe le sien à côté du tien : tu compares, puis tu supprimes lun des deux.',
'awed.importRefs': '{n} référence(s)',
'awed.importNew': 'Nouveau — sera ajouté.',
'awed.importExists': 'Tu as déjà « {name} » avec {n} référence(s).',
'awed.importProtected': 'intégré',
'awed.importKeepMine': 'Garder le mien',
'awed.importReplace': 'Remplacer le mien',
'awed.importCopy': 'Importer en {code}-2',
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…', 'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}', 'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}', 'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…', 'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé', 'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
+2 -2
View File
@@ -5,11 +5,11 @@ import { writeUiPref } from './uiPref';
// CSS variables in style.css key off of. 'auto' follows the OS light/dark // CSS variables in style.css key off of. 'auto' follows the OS light/dark
// preference. The choice is persisted (localStorage + portable UI pref, so it // preference. The choice is persisted (localStorage + portable UI pref, so it
// travels with the data/ folder like the language). // travels with the data/ folder like the language).
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast'; export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
// Selectable, concrete themes (excludes 'auto') in display order. // Selectable, concrete themes (excludes 'auto') in display order.
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [ export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast', 'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
]; ];
export const LS_KEY = 'opslog.theme'; export const LS_KEY = 'opslog.theme';
+258
View File
@@ -92,6 +92,207 @@
color-scheme: light; color-scheme: light;
} }
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
[data-theme="light-cool"] {
--background: #f4f6f8; /* cool blue-white */
--foreground: #0f172a; /* slate-900 */
--card: #ffffff;
--card-foreground: #0f172a;
--popover: #ffffff;
--popover-foreground: #0f172a;
--primary: #2563eb; /* blue-600 */
--primary-foreground: #ffffff;
--secondary: #e2e8f0; /* slate-200 */
--secondary-foreground: #0f172a;
--muted: #e6ebf1; /* toolbars / table headers */
--muted-foreground: #475569; /* slate-600 — readable on muted */
--accent: #dbeafe; /* blue-100 */
--accent-foreground: #1e3a8a; /* blue-900 */
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #d3dae2; /* cool slate border */
--input: #d3dae2;
--ring: #3b82f6; /* blue-500 focus ring */
--success: #16a34a;
--success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #166534;
--success-border: #86efac;
--warning: #d97706;
--warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--caution: #ca8a04;
--caution-foreground: #1c1917;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--danger: #e11d48;
--danger-foreground: #ffffff;
--danger-muted: #ffe4e6;
--danger-muted-foreground: #9f1239;
--danger-border: #fda4af;
--info: #0284c7;
--info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
--mx-call-conf: #15803d;
--mx-call-work: #6ee7b7;
--mx-dx-conf: #3730a3;
--mx-dx-work: #a5b4fc;
--mx-none: #e2e8f0;
--scrollbar-thumb: #b6c2d1;
--scrollbar-thumb-hover: #8fa1b5;
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
color-scheme: light;
}
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
[data-theme="light-sage"] {
--background: #eef1ec; /* pale sage */
--foreground: #1a2420; /* deep pine-ink */
--card: #f8faf6; /* lifted off-white with a green cast */
--card-foreground: #1a2420;
--popover: #f8faf6;
--popover-foreground: #1a2420;
--primary: #2f855a; /* deep sage green */
--primary-foreground: #f0fdf4;
--secondary: #dde5db;
--secondary-foreground: #1a2420;
--muted: #dde5db; /* toolbars / table headers */
--muted-foreground: #445048; /* muted pine — readable on muted */
--accent: #d7e8d5; /* soft green */
--accent-foreground: #1e4634; /* forest-900 */
--destructive: #b91c1c;
--destructive-foreground: #ffffff;
--destructive-muted: #fef2f2;
--destructive-muted-foreground: #b91c1c;
--border: #cdd8ce; /* soft sage border */
--input: #cdd8ce;
--ring: #38a169; /* green focus ring */
--success: #16a34a;
--success-foreground: #ffffff;
--success-muted: #dcfce7;
--success-muted-foreground: #166534;
--success-border: #86efac;
--warning: #d97706;
--warning-foreground: #ffffff;
--warning-muted: #fef3c7;
--warning-muted-foreground: #92400e;
--warning-border: #fcd34d;
--caution: #ca8a04;
--caution-foreground: #1c1917;
--caution-muted: #fef9c3;
--caution-muted-foreground: #854d0e;
--caution-border: #fde047;
--danger: #e11d48;
--danger-foreground: #ffffff;
--danger-muted: #ffe4e6;
--danger-muted-foreground: #9f1239;
--danger-border: #fda4af;
--info: #0284c7;
--info-foreground: #ffffff;
--info-muted: #e0f2fe;
--info-muted-foreground: #075985;
--info-border: #7dd3fc;
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
--mx-call-conf: #15803d;
--mx-call-work: #6ee7b7;
--mx-dx-conf: #3730a3;
--mx-dx-work: #a5b4fc;
--mx-none: #dde5db;
--scrollbar-thumb: #b3c2b3;
--scrollbar-thumb-hover: #91a591;
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
color-scheme: light;
}
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
[data-theme="dim-slate"] {
--background: #343b47; /* medium slate — light text, not deep dark */
--foreground: #eceef2;
--card: #3d4552; /* lifted panel */
--card-foreground: #eceef2;
--popover: #3d4552;
--popover-foreground: #eceef2;
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
--primary-foreground: #241206;
--secondary: #454e5c;
--secondary-foreground: #eceef2;
--muted: #414a58; /* toolbars / table headers */
--muted-foreground: #b4bcc9; /* readable on muted */
--accent: #495468; /* hover / selection tint */
--accent-foreground: #dce3ef;
--destructive: #ef4444;
--destructive-foreground: #1a0808;
--destructive-muted: #3f1f1f;
--destructive-muted-foreground: #fca5a5;
--border: #4e5867;
--input: #4e5867;
--ring: #fdba74; /* orange-300 focus ring */
--success: #34d399;
--success-foreground: #06231a;
--success-muted: #1c3d34;
--success-muted-foreground: #6ee7b7;
--success-border: #2a5f4c;
--warning: #fbbf24;
--warning-foreground: #241a06;
--warning-muted: #41371a;
--warning-muted-foreground: #fcd34d;
--warning-border: #5e4d22;
--caution: #facc15;
--caution-foreground: #242006;
--caution-muted: #403b1a;
--caution-muted-foreground: #fde047;
--caution-border: #5b5222;
--danger: #fb7185;
--danger-foreground: #260a11;
--danger-muted: #421f28;
--danger-muted-foreground: #fda4af;
--danger-border: #63303c;
--info: #38bdf8;
--info-foreground: #061f2b;
--info-muted: #16384a;
--info-muted-foreground: #7dd3fc;
--info-border: #1d5670;
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
--mx-call-conf: #22c55e;
--mx-call-work: #2f7d55;
--mx-dx-conf: #6366f1;
--mx-dx-work: #3f3f8a;
--mx-none: #4a5262;
--scrollbar-thumb: #565f70;
--scrollbar-thumb-hover: #6b7588;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
color-scheme: dark;
}
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */ /* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
[data-theme="dark-warm"] { [data-theme="dark-warm"] {
--background: #221d18; --background: #221d18;
@@ -294,6 +495,63 @@
color-scheme: dark; color-scheme: dark;
} }
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
The dark column is the same eight hues re-stepped for a dark surface, NOT an
automatic inversion.
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
every bar. Colour is for identity, never to re-encode a length the bar already
shows.
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
themes it runs light→dark; on dark themes dark→light, so "near zero" always
recedes toward the surface instead of glowing. */
:root,
[data-theme="light-warm"],
[data-theme="light-cool"],
[data-theme="light-sage"] {
--chart-1: #2a78d6; /* blue — default single-series hue */
--chart-2: #1baf7a; /* aqua */
--chart-3: #eda100; /* yellow */
--chart-4: #008300; /* green */
--chart-5: #4a3aa7; /* violet */
--chart-6: #e34948; /* red */
--chart-7: #e87ba4; /* magenta */
--chart-8: #eb6834; /* orange */
--chart-seq-1: #cde2fb;
--chart-seq-2: #9ec5f4;
--chart-seq-3: #6da7ec;
--chart-seq-4: #3987e5;
--chart-seq-5: #256abf;
--chart-seq-6: #104281;
}
[data-theme="dim-slate"],
[data-theme="dark-warm"],
[data-theme="dark-graphite"],
[data-theme="high-contrast"] {
--chart-1: #3987e5;
--chart-2: #199e70;
--chart-3: #c98500;
--chart-4: #008300;
--chart-5: #9085e9;
--chart-6: #e66767;
--chart-7: #d55181;
--chart-8: #d95926;
/* Reversed: the lowest step must sink toward the dark surface. */
--chart-seq-1: #0d366b;
--chart-seq-2: #184f95;
--chart-seq-3: #256abf;
--chart-seq-4: #3987e5;
--chart-seq-5: #6da7ec;
--chart-seq-6: #9ec5f4;
}
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes /* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
the generated utilities reference var(--…) directly, so overriding a var in the generated utilities reference var(--…) directly, so overriding a var in
a [data-theme] block re-skins every utility at runtime. */ a [data-theme] block re-skins every utility at runtime. */
+1 -1
View File
@@ -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.19'; export const APP_VERSION = '0.19.7';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+70
View File
@@ -11,12 +11,14 @@ import {awardref} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {powergenius} from '../models'; import {powergenius} from '../models';
import {solar} from '../models';
import {winkeyer} from '../models'; import {winkeyer} from '../models';
import {alerts} from '../models'; import {alerts} from '../models';
import {audio} from '../models'; import {audio} from '../models';
import {contest} from '../models'; import {contest} from '../models';
import {operating} from '../models'; import {operating} from '../models';
import {udp} from '../models'; import {udp} from '../models';
import {lotwusers} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models'; import {netctl} from '../models';
@@ -32,10 +34,26 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>; export function AntGeniusDeselect(arg1:number):Promise<void>;
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>; export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>;
export function AudioStartTX():Promise<void>;
export function AudioStopMonitor():Promise<void>;
export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>; export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
@@ -44,6 +62,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>; export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function AwardsFolder():Promise<string>;
export function BrowseExecutable():Promise<string>; export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>; export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -120,20 +140,28 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>; export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>; export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>; export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>; export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
export function DownloadLoTWUsers():Promise<number>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>; export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>; export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>; export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwards():Promise<string>; export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>; export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -190,6 +218,8 @@ export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>; export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>; export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>; export function FlexSetMonLevel(arg1:number):Promise<void>;
@@ -210,6 +240,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRIT(arg1:boolean):Promise<void>;
export function FlexSetRITFreq(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>; export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
@@ -218,6 +252,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>; export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>; export function FlexSetTXSlice(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
@@ -228,6 +264,14 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>; export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexSetWNB(arg1:boolean):Promise<void>;
export function FlexSetWNBLevel(arg1:number):Promise<void>;
export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>; export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>; export function GetActiveProfile():Promise<profile.Profile>;
@@ -252,6 +296,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>; export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
export function GetAwards():Promise<Array<award.Result>>; export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>; export function GetBackupSettings():Promise<main.BackupSettings>;
@@ -262,6 +308,8 @@ export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>; export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>; export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>; export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -270,6 +318,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>; export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDBBackendStatus():Promise<main.DBBackendStatus>; export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
@@ -298,14 +348,20 @@ export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>; export function GetLiveStatusEnabled():Promise<boolean>;
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
export function GetLogFilePath():Promise<string>; export function GetLogFilePath():Promise<string>;
export function GetLogStats(arg1:string,arg2:string,arg3:string,arg4:number):Promise<qso.Stats>;
export function GetLogbookRevision():Promise<string>; export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>; export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>; export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>; export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>; export function GetPGXLSettings():Promise<main.PGXLSettings>;
@@ -314,6 +370,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>; export function GetQSO(arg1:number):Promise<qso.QSO>;
@@ -324,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSecretStatus():Promise<main.SecretStatus>; export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSolarData():Promise<solar.Data>;
export function GetStartupStatus():Promise<main.StartupStatus>; export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>; export function GetStationSettings():Promise<main.StationSettings>;
@@ -434,6 +494,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
export function ImportAwards():Promise<main.AwardImportResult>; export function ImportAwards():Promise<main.AwardImportResult>;
export function InspectAwardImport():Promise<main.AwardImportPreview>;
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>; export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>; export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
@@ -466,6 +528,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
export function ListUDPIntegrations():Promise<Array<udp.Config>>; export function ListUDPIntegrations():Promise<Array<udp.Config>>;
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
export function LogUDPLoggedADIF(arg1:string):Promise<number>; export function LogUDPLoggedADIF(arg1:string):Promise<number>;
export function LookupCallsign(arg1:string):Promise<lookup.Result>; export function LookupCallsign(arg1:string):Promise<lookup.Result>;
@@ -508,6 +572,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>;
export function OpenDatabase(arg1:string):Promise<void>; export function OpenDatabase(arg1:string):Promise<void>;
export function OpenExternalURL(arg1:string):Promise<void>; export function OpenExternalURL(arg1:string):Promise<void>;
@@ -572,6 +638,8 @@ export function QuitApp():Promise<void>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>;
export function ReloadUDPIntegrations():Promise<Array<string>>; export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>; export function RemovePassphrase(arg1:string):Promise<void>;
@@ -592,6 +660,8 @@ export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>; export function RestartQSORecorder():Promise<void>;
export function RetryOfflineSync():Promise<number>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
+136
View File
@@ -26,14 +26,46 @@ export function AntGeniusDeselect(arg1) {
return window['go']['main']['App']['AntGeniusDeselect'](arg1); return window['go']['main']['App']['AntGeniusDeselect'](arg1);
} }
export function ApplyAwardImport(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
}
export function ApplyAwardPreset(arg1, arg2) { export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
} }
export function ApplyAwardUpdate(arg1) {
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
}
export function AssignAwardRefToQSOs(arg1, arg2, arg3) { export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
} }
export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive']();
}
export function AudioStartMonitor() {
return window['go']['main']['App']['AudioStartMonitor']();
}
export function AudioStartTX() {
return window['go']['main']['App']['AudioStartTX']();
}
export function AudioStopMonitor() {
return window['go']['main']['App']['AudioStopMonitor']();
}
export function AudioStopTX() {
return window['go']['main']['App']['AudioStopTX']();
}
export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive']();
}
export function AwardCellQSOs(arg1, arg2, arg3) { export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
} }
@@ -50,6 +82,10 @@ export function AwardRefsForQSOs(arg1) {
return window['go']['main']['App']['AwardRefsForQSOs'](arg1); return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
} }
export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder']();
}
export function BrowseExecutable() { export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable'](); return window['go']['main']['App']['BrowseExecutable']();
} }
@@ -202,6 +238,10 @@ export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios'](); return window['go']['main']['App']['DiscoverFlexRadios']();
} }
export function DismissAwardUpdate(arg1) {
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
}
export function DownloadAllReferenceLists() { export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists'](); return window['go']['main']['App']['DownloadAllReferenceLists']();
} }
@@ -214,10 +254,18 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3); return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
} }
export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers']();
}
export function DuplicateProfile(arg1, arg2) { export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
}
export function ExportADIF(arg1, arg2) { export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2); return window['go']['main']['App']['ExportADIF'](arg1, arg2);
} }
@@ -230,6 +278,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3); return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
} }
export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwards() { export function ExportAwards() {
return window['go']['main']['App']['ExportAwards'](); return window['go']['main']['App']['ExportAwards']();
} }
@@ -342,6 +394,10 @@ export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
export function FlexSetMicProfile(arg1) {
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
}
export function FlexSetMon(arg1) { export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1); return window['go']['main']['App']['FlexSetMon'](arg1);
} }
@@ -382,6 +438,14 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetRIT(arg1) {
return window['go']['main']['App']['FlexSetRIT'](arg1);
}
export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
}
export function FlexSetRXAntenna(arg1) { export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1); return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
} }
@@ -398,6 +462,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1); return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
} }
export function FlexSetTXFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
}
export function FlexSetTXSlice(arg1) { export function FlexSetTXSlice(arg1) {
return window['go']['main']['App']['FlexSetTXSlice'](arg1); return window['go']['main']['App']['FlexSetTXSlice'](arg1);
} }
@@ -418,6 +486,22 @@ export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1); return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
} }
export function FlexSetWNB(arg1) {
return window['go']['main']['App']['FlexSetWNB'](arg1);
}
export function FlexSetWNBLevel(arg1) {
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
}
export function FlexSetXIT(arg1) {
return window['go']['main']['App']['FlexSetXIT'](arg1);
}
export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexTune(arg1) { export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1); return window['go']['main']['App']['FlexTune'](arg1);
} }
@@ -466,6 +550,10 @@ export function GetAwardStats(arg1) {
return window['go']['main']['App']['GetAwardStats'](arg1); return window['go']['main']['App']['GetAwardStats'](arg1);
} }
export function GetAwardUpdates() {
return window['go']['main']['App']['GetAwardUpdates']();
}
export function GetAwards() { export function GetAwards() {
return window['go']['main']['App']['GetAwards'](); return window['go']['main']['App']['GetAwards']();
} }
@@ -486,6 +574,10 @@ export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch'](); return window['go']['main']['App']['GetCWDecoderPitch']();
} }
export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes']();
}
export function GetChatHistory(arg1) { export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1); return window['go']['main']['App']['GetChatHistory'](arg1);
} }
@@ -502,6 +594,10 @@ export function GetClusterStatus() {
return window['go']['main']['App']['GetClusterStatus'](); return window['go']['main']['App']['GetClusterStatus']();
} }
export function GetContestRuns() {
return window['go']['main']['App']['GetContestRuns']();
}
export function GetCtyDatInfo() { export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
@@ -558,10 +654,18 @@ export function GetLiveStatusEnabled() {
return window['go']['main']['App']['GetLiveStatusEnabled'](); return window['go']['main']['App']['GetLiveStatusEnabled']();
} }
export function GetLoTWUsersStatus() {
return window['go']['main']['App']['GetLoTWUsersStatus']();
}
export function GetLogFilePath() { export function GetLogFilePath() {
return window['go']['main']['App']['GetLogFilePath'](); return window['go']['main']['App']['GetLogFilePath']();
} }
export function GetLogStats(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
}
export function GetLogbookRevision() { export function GetLogbookRevision() {
return window['go']['main']['App']['GetLogbookRevision'](); return window['go']['main']['App']['GetLogbookRevision']();
} }
@@ -574,6 +678,10 @@ export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings'](); return window['go']['main']['App']['GetMySQLSettings']();
} }
export function GetOfflineStatus() {
return window['go']['main']['App']['GetOfflineStatus']();
}
export function GetOnlineOperators() { export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators'](); return window['go']['main']['App']['GetOnlineOperators']();
} }
@@ -590,6 +698,10 @@ export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
export function GetPendingQSOs() {
return window['go']['main']['App']['GetPendingQSOs']();
}
export function GetQSLDefaults() { export function GetQSLDefaults() {
return window['go']['main']['App']['GetQSLDefaults'](); return window['go']['main']['App']['GetQSLDefaults']();
} }
@@ -610,6 +722,10 @@ export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus'](); return window['go']['main']['App']['GetSecretStatus']();
} }
export function GetSolarData() {
return window['go']['main']['App']['GetSolarData']();
}
export function GetStartupStatus() { export function GetStartupStatus() {
return window['go']['main']['App']['GetStartupStatus'](); return window['go']['main']['App']['GetStartupStatus']();
} }
@@ -830,6 +946,10 @@ export function ImportAwards() {
return window['go']['main']['App']['ImportAwards'](); return window['go']['main']['App']['ImportAwards']();
} }
export function InspectAwardImport() {
return window['go']['main']['App']['InspectAwardImport']();
}
export function LaunchAutostartProgram(arg1) { export function LaunchAutostartProgram(arg1) {
return window['go']['main']['App']['LaunchAutostartProgram'](arg1); return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
} }
@@ -894,6 +1014,10 @@ export function ListUDPIntegrations() {
return window['go']['main']['App']['ListUDPIntegrations'](); return window['go']['main']['App']['ListUDPIntegrations']();
} }
export function LoTWUserInfo(arg1) {
return window['go']['main']['App']['LoTWUserInfo'](arg1);
}
export function LogUDPLoggedADIF(arg1) { export function LogUDPLoggedADIF(arg1) {
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1); return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
} }
@@ -978,6 +1102,10 @@ export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
export function OpenAwardsFolder() {
return window['go']['main']['App']['OpenAwardsFolder']();
}
export function OpenDatabase(arg1) { export function OpenDatabase(arg1) {
return window['go']['main']['App']['OpenDatabase'](arg1); return window['go']['main']['App']['OpenDatabase'](arg1);
} }
@@ -1106,6 +1234,10 @@ export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
export function RefreshSolar() {
return window['go']['main']['App']['RefreshSolar']();
}
export function ReloadUDPIntegrations() { export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations'](); return window['go']['main']['App']['ReloadUDPIntegrations']();
} }
@@ -1146,6 +1278,10 @@ export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder'](); return window['go']['main']['App']['RestartQSORecorder']();
} }
export function RetryOfflineSync() {
return window['go']['main']['App']['RetryOfflineSync']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
+529 -4
View File
@@ -117,6 +117,7 @@ export namespace antgenius {
export class Antenna { export class Antenna {
index: number; index: number;
name: string; name: string;
bands: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Antenna(source); return new Antenna(source);
@@ -126,6 +127,7 @@ export namespace antgenius {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.index = source["index"]; this.index = source["index"];
this.name = source["name"]; this.name = source["name"];
this.bands = source["bands"];
} }
} }
export class Status { export class Status {
@@ -250,6 +252,7 @@ export namespace award {
valid_from?: string; valid_from?: string;
valid_to?: string; valid_to?: string;
alias?: string; alias?: string;
ref_display?: string;
type?: string; type?: string;
field: string; field: string;
match_by?: string; match_by?: string;
@@ -257,9 +260,7 @@ export namespace award {
pattern: string; pattern: string;
leading_str?: string; leading_str?: string;
trailing_str?: string; trailing_str?: string;
multi?: boolean;
dynamic?: boolean; dynamic?: boolean;
add_prefixes?: string[];
or_rules?: OrRule[]; or_rules?: OrRule[];
dxcc_filter: number[]; dxcc_filter: number[];
valid_bands?: string[]; valid_bands?: string[];
@@ -271,6 +272,8 @@ export namespace award {
export_credit_granted?: boolean; export_credit_granted?: boolean;
total: number; total: number;
builtin: boolean; builtin: boolean;
version?: number;
user_edited?: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Def(source); return new Def(source);
@@ -289,6 +292,7 @@ export namespace award {
this.valid_from = source["valid_from"]; this.valid_from = source["valid_from"];
this.valid_to = source["valid_to"]; this.valid_to = source["valid_to"];
this.alias = source["alias"]; this.alias = source["alias"];
this.ref_display = source["ref_display"];
this.type = source["type"]; this.type = source["type"];
this.field = source["field"]; this.field = source["field"];
this.match_by = source["match_by"]; this.match_by = source["match_by"];
@@ -296,9 +300,7 @@ export namespace award {
this.pattern = source["pattern"]; this.pattern = source["pattern"];
this.leading_str = source["leading_str"]; this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"]; this.trailing_str = source["trailing_str"];
this.multi = source["multi"];
this.dynamic = source["dynamic"]; this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"];
this.or_rules = this.convertValues(source["or_rules"], OrRule); this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"]; this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"]; this.valid_bands = source["valid_bands"];
@@ -310,6 +312,116 @@ export namespace award {
this.export_credit_granted = source["export_credit_granted"]; this.export_credit_granted = source["export_credit_granted"];
this.total = source["total"]; this.total = source["total"];
this.builtin = source["builtin"]; this.builtin = source["builtin"];
this.version = source["version"];
this.user_edited = source["user_edited"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Rejected {
candidate: string;
reason: string;
static createFrom(source: any = {}) {
return new Rejected(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.candidate = source["candidate"];
this.reason = source["reason"];
}
}
export class Step {
rule: string;
field: string;
match_by?: string;
exact?: boolean;
pattern?: string;
field_value?: string;
candidates?: string[];
kept?: string[];
rejected?: Rejected[];
skipped?: boolean;
error?: string;
static createFrom(source: any = {}) {
return new Step(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.rule = source["rule"];
this.field = source["field"];
this.match_by = source["match_by"];
this.exact = source["exact"];
this.pattern = source["pattern"];
this.field_value = source["field_value"];
this.candidates = source["candidates"];
this.kept = source["kept"];
this.rejected = this.convertValues(source["rejected"], Rejected);
this.skipped = source["skipped"];
this.error = source["error"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Explanation {
code: string;
in_scope: boolean;
scope_error?: string;
predefined: boolean;
ref_count: number;
steps: Step[];
manual?: string[];
result: string[];
static createFrom(source: any = {}) {
return new Explanation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.in_scope = source["in_scope"];
this.scope_error = source["scope_error"];
this.predefined = source["predefined"];
this.ref_count = source["ref_count"];
this.steps = this.convertValues(source["steps"], Step);
this.manual = source["manual"];
this.result = source["result"];
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -361,6 +473,7 @@ export namespace award {
this.validated_bands = source["validated_bands"]; this.validated_bands = source["validated_bands"];
} }
} }
export class Result { export class Result {
code: string; code: string;
name: string; name: string;
@@ -500,6 +613,7 @@ export namespace cat {
src?: string; src?: string;
name?: string; name?: string;
unit?: string; unit?: string;
slice: number;
value: number; value: number;
lo: number; lo: number;
hi: number; hi: number;
@@ -514,6 +628,7 @@ export namespace cat {
this.src = source["src"]; this.src = source["src"];
this.name = source["name"]; this.name = source["name"];
this.unit = source["unit"]; this.unit = source["unit"];
this.slice = source["slice"];
this.value = source["value"]; this.value = source["value"];
this.lo = source["lo"]; this.lo = source["lo"];
this.hi = source["hi"]; this.hi = source["hi"];
@@ -581,6 +696,10 @@ export namespace cat {
mon: boolean; mon: boolean;
mon_level: number; mon_level: number;
mic_level: number; mic_level: number;
tx_filter_low: number;
tx_filter_high: number;
mic_profile?: string;
mic_profiles?: string[];
atu_status?: string; atu_status?: string;
atu_memories: boolean; atu_memories: boolean;
rx_avail: boolean; rx_avail: boolean;
@@ -601,6 +720,12 @@ export namespace cat {
nr_level: number; nr_level: number;
anf: boolean; anf: boolean;
anf_level: number; anf_level: number;
wnb: boolean;
wnb_level: number;
rit: boolean;
rit_freq: number;
xit: boolean;
xit_freq: number;
mode?: string; mode?: string;
cw_speed: number; cw_speed: number;
cw_pitch: number; cw_pitch: number;
@@ -638,6 +763,10 @@ export namespace cat {
this.mon = source["mon"]; this.mon = source["mon"];
this.mon_level = source["mon_level"]; this.mon_level = source["mon_level"];
this.mic_level = source["mic_level"]; this.mic_level = source["mic_level"];
this.tx_filter_low = source["tx_filter_low"];
this.tx_filter_high = source["tx_filter_high"];
this.mic_profile = source["mic_profile"];
this.mic_profiles = source["mic_profiles"];
this.atu_status = source["atu_status"]; this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"]; this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"]; this.rx_avail = source["rx_avail"];
@@ -658,6 +787,12 @@ export namespace cat {
this.nr_level = source["nr_level"]; this.nr_level = source["nr_level"];
this.anf = source["anf"]; this.anf = source["anf"];
this.anf_level = source["anf_level"]; this.anf_level = source["anf_level"];
this.wnb = source["wnb"];
this.wnb_level = source["wnb_level"];
this.rit = source["rit"];
this.rit_freq = source["rit_freq"];
this.xit = source["xit"];
this.xit_freq = source["xit_freq"];
this.mode = source["mode"]; this.mode = source["mode"];
this.cw_speed = source["cw_speed"]; this.cw_speed = source["cw_speed"];
this.cw_pitch = source["cw_pitch"]; this.cw_pitch = source["cw_pitch"];
@@ -1138,6 +1273,27 @@ export namespace lookup {
} }
export namespace lotwusers {
export class Info {
is_user: boolean;
last_upload?: string;
days_ago: number;
static createFrom(source: any = {}) {
return new Info(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.is_user = source["is_user"];
this.last_upload = source["last_upload"];
this.days_ago = source["days_ago"];
}
}
}
export namespace main { export namespace main {
export class AntGeniusSettings { export class AntGeniusSettings {
@@ -1228,6 +1384,95 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
} }
} }
export class AwardExplain {
qso: qso.QSO;
explanation: award.Explanation;
static createFrom(source: any = {}) {
return new AwardExplain(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.qso = this.convertValues(source["qso"], qso.QSO);
this.explanation = this.convertValues(source["explanation"], award.Explanation);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class AwardImportPreviewEntry {
code: string;
name: string;
references: number;
exists: boolean;
mine_name: string;
mine_refs: number;
protected: boolean;
static createFrom(source: any = {}) {
return new AwardImportPreviewEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.references = source["references"];
this.exists = source["exists"];
this.mine_name = source["mine_name"];
this.mine_refs = source["mine_refs"];
this.protected = source["protected"];
}
}
export class AwardImportPreview {
path: string;
awards: AwardImportPreviewEntry[];
static createFrom(source: any = {}) {
return new AwardImportPreview(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.awards = this.convertValues(source["awards"], AwardImportPreviewEntry);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class AwardImportResult { export class AwardImportResult {
awards: number; awards: number;
references: number; references: number;
@@ -1312,6 +1557,24 @@ export namespace main {
return a; return a;
} }
} }
export class AwardUpdate {
code: string;
name: string;
from: number;
to: number;
static createFrom(source: any = {}) {
return new AwardUpdate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.code = source["code"];
this.name = source["name"];
this.from = source["from"];
this.to = source["to"];
}
}
export class BackupSettings { export class BackupSettings {
enabled: boolean; enabled: boolean;
folder: string; folder: string;
@@ -1341,12 +1604,15 @@ export namespace main {
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
icom_net_host: string; icom_net_host: string;
icom_net_user: string; icom_net_user: string;
icom_net_pass: string; icom_net_pass: string;
icom_net_audio: boolean;
tci_host: string; tci_host: string;
tci_port: number; tci_port: number;
tci_spots: boolean; tci_spots: boolean;
@@ -1366,12 +1632,15 @@ export namespace main {
this.flex_host = source["flex_host"]; this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"]; this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
this.icom_net_host = source["icom_net_host"]; this.icom_net_host = source["icom_net_host"];
this.icom_net_user = source["icom_net_user"]; this.icom_net_user = source["icom_net_user"];
this.icom_net_pass = source["icom_net_pass"]; this.icom_net_pass = source["icom_net_pass"];
this.icom_net_audio = source["icom_net_audio"];
this.tci_host = source["tci_host"]; this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"]; this.tci_port = source["tci_port"];
this.tci_spots = source["tci_spots"]; this.tci_spots = source["tci_spots"];
@@ -1720,6 +1989,20 @@ export namespace main {
return a; return a;
} }
} }
export class LoTWUsersStatus {
count: number;
updated?: string;
static createFrom(source: any = {}) {
return new LoTWUsersStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.updated = source["updated"];
}
}
export class LookupSettings { export class LookupSettings {
qrz_user: string; qrz_user: string;
qrz_password: string; qrz_password: string;
@@ -1769,6 +2052,22 @@ export namespace main {
this.database = source["database"]; this.database = source["database"];
} }
} }
export class OfflineStatus {
offline: boolean;
pending: number;
path: string;
static createFrom(source: any = {}) {
return new OfflineStatus(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.offline = source["offline"];
this.pending = source["pending"];
this.path = source["path"];
}
}
export class PGXLSettings { export class PGXLSettings {
enabled: boolean; enabled: boolean;
host: string; host: string;
@@ -2166,8 +2465,12 @@ export namespace main {
} }
export class UltrabeamSettings { export class UltrabeamSettings {
enabled: boolean; enabled: boolean;
type: string;
transport: string;
host: string; host: string;
port: number; port: number;
com: string;
baud: number;
follow: boolean; follow: boolean;
step_khz: number; step_khz: number;
@@ -2178,8 +2481,12 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"]; this.host = source["host"];
this.port = source["port"]; this.port = source["port"];
this.com = source["com"];
this.baud = source["baud"];
this.follow = source["follow"]; this.follow = source["follow"];
this.step_khz = source["step_khz"]; this.step_khz = source["step_khz"];
} }
@@ -2317,6 +2624,14 @@ export namespace netctl {
dxcc?: number; dxcc?: number;
itu?: number; itu?: number;
cq?: number; cq?: number;
grid?: string;
address?: string;
state?: string;
cnty?: string;
cont?: string;
lat?: number;
lon?: number;
email?: string;
groups?: string; groups?: string;
sig?: string; sig?: string;
sig_info?: string; sig_info?: string;
@@ -2334,6 +2649,14 @@ export namespace netctl {
this.dxcc = source["dxcc"]; this.dxcc = source["dxcc"];
this.itu = source["itu"]; this.itu = source["itu"];
this.cq = source["cq"]; this.cq = source["cq"];
this.grid = source["grid"];
this.address = source["address"];
this.state = source["state"];
this.cnty = source["cnty"];
this.cont = source["cont"];
this.lat = source["lat"];
this.lon = source["lon"];
this.email = source["email"];
this.groups = source["groups"]; this.groups = source["groups"];
this.sig = source["sig"]; this.sig = source["sig"];
this.sig_info = source["sig_info"]; this.sig_info = source["sig_info"];
@@ -2831,6 +3154,20 @@ export namespace qso {
this.status = source["status"]; this.status = source["status"];
} }
} }
export class Bucket {
key: string;
count: number;
static createFrom(source: any = {}) {
return new Bucket(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.count = source["count"];
}
}
export class Condition { export class Condition {
field: string; field: string;
op: string; op: string;
@@ -2847,6 +3184,42 @@ export namespace qso {
this.value = source["value"]; this.value = source["value"];
} }
} }
export class ContestRun {
id: string;
year: number;
count: number;
start: string;
end: string;
static createFrom(source: any = {}) {
return new ContestRun(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.year = source["year"];
this.count = source["count"];
this.start = source["start"];
this.end = source["end"];
}
}
export class Gap {
start: string;
end: string;
minutes: number;
static createFrom(source: any = {}) {
return new Gap(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.start = source["start"];
this.end = source["end"];
this.minutes = source["minutes"];
}
}
export class ListFilter { export class ListFilter {
callsign?: string; callsign?: string;
band?: string; band?: string;
@@ -3195,6 +3568,98 @@ export namespace qso {
return a; return a;
} }
} }
export class Stats {
total: number;
unique_calls: number;
entities: number;
continents: number;
first_qso: string;
last_qso: string;
confirmed_lotw: number;
confirmed_eqsl: number;
confirmed_qsl: number;
confirmed_any: number;
by_mode: Bucket[];
by_band: Bucket[];
by_operator: Bucket[];
by_station: Bucket[];
by_continent: Bucket[];
top_entities: Bucket[];
by_year: Bucket[];
by_month: Bucket[];
window_start: string;
window_end: string;
window_hours: number;
avg_per_hour: number;
avg_per_active: number;
on_air_minutes: number;
off_air_minutes: number;
peak_hour_key: string;
peak_hour_count: number;
best_60: number;
gaps: Gap[];
rate: Bucket[];
rate_ops: string[];
rate_by_op: number[][];
static createFrom(source: any = {}) {
return new Stats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.total = source["total"];
this.unique_calls = source["unique_calls"];
this.entities = source["entities"];
this.continents = source["continents"];
this.first_qso = source["first_qso"];
this.last_qso = source["last_qso"];
this.confirmed_lotw = source["confirmed_lotw"];
this.confirmed_eqsl = source["confirmed_eqsl"];
this.confirmed_qsl = source["confirmed_qsl"];
this.confirmed_any = source["confirmed_any"];
this.by_mode = this.convertValues(source["by_mode"], Bucket);
this.by_band = this.convertValues(source["by_band"], Bucket);
this.by_operator = this.convertValues(source["by_operator"], Bucket);
this.by_station = this.convertValues(source["by_station"], Bucket);
this.by_continent = this.convertValues(source["by_continent"], Bucket);
this.top_entities = this.convertValues(source["top_entities"], Bucket);
this.by_year = this.convertValues(source["by_year"], Bucket);
this.by_month = this.convertValues(source["by_month"], Bucket);
this.window_start = source["window_start"];
this.window_end = source["window_end"];
this.window_hours = source["window_hours"];
this.avg_per_hour = source["avg_per_hour"];
this.avg_per_active = source["avg_per_active"];
this.on_air_minutes = source["on_air_minutes"];
this.off_air_minutes = source["off_air_minutes"];
this.peak_hour_key = source["peak_hour_key"];
this.peak_hour_count = source["peak_hour_count"];
this.best_60 = source["best_60"];
this.gaps = this.convertValues(source["gaps"], Gap);
this.rate = this.convertValues(source["rate"], Bucket);
this.rate_ops = source["rate_ops"];
this.rate_by_op = source["rate_by_op"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
@@ -3264,6 +3729,66 @@ export namespace qso {
} }
export namespace solar {
export class Data {
sfi: string;
ssn: string;
a_index: string;
k_index: string;
xray: string;
geomag_field: string;
aurora: string;
muf: string;
updated: string;
source: string;
ok: boolean;
error?: string;
// Go type: time
fetched_at: any;
static createFrom(source: any = {}) {
return new Data(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.sfi = source["sfi"];
this.ssn = source["ssn"];
this.a_index = source["a_index"];
this.k_index = source["k_index"];
this.xray = source["xray"];
this.geomag_field = source["geomag_field"];
this.aurora = source["aurora"];
this.muf = source["muf"];
this.updated = source["updated"];
this.source = source["source"];
this.ok = source["ok"];
this.error = source["error"];
this.fetched_at = this.convertValues(source["fetched_at"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace udp { export namespace udp {
export class Config { export class Config {
+12
View File
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String() return b.String()
} }
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
// so it can be written out and read back with nothing dropped. Used by the
// offline queue: a QSO parked in the safety file must come back identical
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
func FullRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, true)
bw.Flush()
return b.String()
}
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>, // BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard // e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php. // header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
+21 -3
View File
@@ -32,9 +32,13 @@ const (
) )
// Antenna is one configured antenna (index + name as stored on the device). // Antenna is one configured antenna (index + name as stored on the device).
// Bands is the device's band bitmask for the antenna (which bands it covers);
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
// the native 4O3A app.
type Antenna struct { type Antenna struct {
Index int `json:"index"` Index int `json:"index"`
Name string `json:"name"` Name string `json:"name"`
Bands int `json:"bands"`
} }
// Status is the snapshot the UI renders. // Status is the snapshot the UI renders.
@@ -65,6 +69,8 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
antennas map[int]string // index → name (rebuilt into status.Antennas) antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
stop chan struct{} stop chan struct{}
running bool running bool
@@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client {
password: strings.TrimSpace(password), password: strings.TrimSpace(password),
stop: make(chan struct{}), stop: make(chan struct{}),
antennas: map[int]string{}, antennas: map[int]string{},
antBands: map[int]int{},
status: Status{Host: host}, status: Status{Host: host},
} }
} }
@@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
// The device stores spaces as underscores in names. // The device stores spaces as underscores in names.
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
} }
// Band bitmask: which bands this antenna is configured for. The device sends
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
// antennas (like the native app). Log the first few raw antenna lines so the
// exact field name + bit values can be confirmed on real hardware.
bands := kvInt(msg, "band")
c.statusMu.Lock() c.statusMu.Lock()
if c.antRawN < 8 {
c.antRawN++
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
}
if name != "" && !isPlaceholderName(name) { if name != "" && !isPlaceholderName(name) {
c.antennas[id] = name c.antennas[id] = name
c.antBands[id] = bands
} else { } else {
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
delete(c.antBands, id)
} }
c.status.Antennas = sortedAntennas(c.antennas) c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
c.status.Connected = true c.status.Connected = true
c.statusMu.Unlock() c.statusMu.Unlock()
} }
@@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool {
return err == nil return err == nil
} }
func sortedAntennas(m map[int]string) []Antenna { func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
out := make([]Antenna, 0, len(m)) out := make([]Antenna, 0, len(m))
for idx, name := range m { for idx, name := range m {
out = append(out, Antenna{Index: idx, Name: name}) out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
} }
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out return out
+57
View File
@@ -0,0 +1,57 @@
package audio
import "fmt"
// Codec converts between the wire payload of a network audio stream (Icom 50003)
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
// capture/render engine and the pcmRing use). It exists so the transport code
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
type Codec interface {
// Name is a short label for logs/UI.
Name() string
// Decode turns one received audio payload into internal PCM. The returned
// slice is freshly allocated (the caller may retain it).
Decode(payload []byte) ([]byte, error)
// Encode turns internal PCM into a payload to transmit.
Encode(pcm []byte) ([]byte, error)
}
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
//
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
// in one place.
type pcm16Codec struct{}
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
func NewPCM16Codec() Codec { return pcm16Codec{} }
func (pcm16Codec) Name() string { return "pcm16" }
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
// packet — trim the stray byte rather than emit a half-sample click.
n := len(payload) &^ 1
if n != len(payload) {
if n == 0 {
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
}
}
out := make([]byte, n)
copy(out, payload[:n])
return out, nil
}
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
out := make([]byte, len(pcm))
copy(out, pcm)
return out, nil
}
+37
View File
@@ -0,0 +1,37 @@
package audio
import "testing"
func TestPCM16CodecRoundTrip(t *testing.T) {
c := NewPCM16Codec()
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
enc, err := c.Encode(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
dec, err := c.Decode(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(dec) != string(in) {
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
}
}
func TestPCM16CodecTrimsOddByte(t *testing.T) {
c := NewPCM16Codec()
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(dec) != 2 {
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
}
}
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
c := NewPCM16Codec()
if _, err := c.Decode([]byte{0x10}); err == nil {
t.Fatalf("expected an error for a sub-sample payload")
}
}
+149
View File
@@ -5,6 +5,7 @@ package audio
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"sync"
"time" "time"
"unsafe" "unsafe"
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
} }
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the WASAPI clock steady so live
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
// COM-initialised, OS-locked thread.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
// feed fills up to `frames` render frames: as much real audio as the ring
// has, the remainder silence (so the buffer stays full and the clock steady).
feed := func(frames int) error {
if frames <= 0 {
return nil
}
var data *byte
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
return err
}
dst := unsafe.Slice(data, frames*frameBytes)
got := src.pull(frames * frameBytes)
n := copy(dst, got)
for i := n; i < len(dst); i++ {
dst[i] = 0 // silence-fill the shortfall
}
arc.ReleaseBuffer(uint32(frames), 0)
return nil
}
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
}
+130
View File
@@ -15,6 +15,9 @@ type Manager struct {
recStop chan struct{} recStop chan struct{}
recDone chan recResult recDone chan recResult
playStop chan struct{} playStop chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
onChange func() // fired on any record/playback state transition onChange func() // fired on any record/playback state transition
} }
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
m.notify() m.notify()
} }
} }
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
//
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
// is later fed by the network 50003 stream instead of a USB capture — the render
// half is transport-agnostic. inputDev "" = system default capture.
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
return m.startMonitor(inputDev, outputDev, true)
}
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
// producer — the network 50003 stream — can feed decoded RX PCM via
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
func (m *Manager) StartMonitorSink(outputDev string) error {
return m.startMonitor("", outputDev, false)
}
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
// when capture is true it also captures inputDev into that ring (USB monitor).
// When false the ring is fed only by PushMonitorAudio (network audio).
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Lock()
if m.monStop != nil {
m.mu.Unlock()
return fmt.Errorf("monitor already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
m.monStop, m.monRing = stop, ring
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
go func() {
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
stop := m.monStop
m.monStop, m.monRing = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// MonitorActive reports whether the RX monitor passthrough is running.
func (m *Manager) MonitorActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.monStop != nil
}
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
// active monitor's output — the hook the network 50003 audio stream uses to play
// decoded RX through the very same output path a USB capture feeds. No-op when no
// monitor is running. Keeps the unexported ring inside the package.
func (m *Manager) PushMonitorAudio(pcm []byte) {
m.mu.Lock()
ring := m.monRing
m.mu.Unlock()
if ring != nil {
ring.Push(pcm)
}
}
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
//
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
// capture + render primitives, source/sink swapped). PTT keying is the caller's
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
// audio route. The captured 16 kHz mono stream is also the exact shape the future
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
m.txStop = stop
m.mu.Unlock()
go func() {
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
go func() {
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() {
m.mu.Lock()
stop := m.txStop
m.txStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// TXAudioActive reports whether the TX mic→rig passthrough is running.
func (m *Manager) TXAudioActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.txStop != nil
}
+403 -80
View File
@@ -13,6 +13,10 @@
package award package award
import ( import (
"embed"
"encoding/json"
"errors"
"fmt"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -55,6 +59,11 @@ type Def struct {
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count) ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count) ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
Alias string `json:"alias,omitempty"` Alias string `json:"alias,omitempty"`
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
// the prefecture name), "both" = "ref — name". DXCC always shows the country
// name under "ref" (unchanged).
RefDisplay string `json:"ref_display,omitempty"`
// --- Type & matching --- // --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS) Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
@@ -64,14 +73,19 @@ type Def struct {
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts) Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes // NOTE: there is no "one reference per QSO" switch, and there was never any
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
// `Multi` flag was read by nothing; its checkbox changed nothing.
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO // OrRules are ordered FALLBACK searches for the primary one above: they are
// earns a reference if the primary match OR any of these match. Lets a // tried IN ORDER and only while nothing has matched yet — the first rule that
// French department (DDFM) be found from "D74" in the note AND from a postal // yields a reference wins and the rest are skipped (short-circuit, like a
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74"). // chain of "else if"). Lets a Chinese province (WAPC) be found first from the
// province NAME in the address, and only if that fails, from a city regex
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
// re-tagged, possibly differently, by a later rule.
OrRules []OrRule `json:"or_rules,omitempty"` OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope --- // --- Scope ---
@@ -83,11 +97,45 @@ type Def struct {
// --- Confirmation --- // --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources Validate []string `json:"validate,omitempty"` // validated/granted sources
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
// internal/adif) before showing them again.
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
Total int `json:"total"` // known denominator (0 = unknown / derive from list) Total int `json:"total"` // known denominator (0 = unknown / derive from list)
Builtin bool `json:"builtin"` // shipped default (informational) Builtin bool `json:"builtin"` // shipped default (informational)
// --- Catalog updates ---
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
// you fix a definition (a better OR chain, a corrected reference list) and want
// that fix to reach operators who already run the award: on startup, a catalog
// award whose Version is higher than the stored one REPLACES it, definition and
// references. Awards created by the operator have no version and are never touched.
Version int `json:"version,omitempty"`
// UserEdited marks an award the operator has changed. A catalog update then
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
// list) is saved to something other than what the catalog ships.
UserEdited bool `json:"user_edited,omitempty"`
}
// SameContent reports whether two definitions describe the same award — ignoring
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
// which say where a definition came from, not what it does. Used to decide whether
// a save actually changed anything.
func (d Def) SameContent(o Def) bool {
a, b := d, o
a.Version, b.Version = 0, 0
a.UserEdited, b.UserEdited = false, false
a.Builtin, b.Builtin = false, false
ja, err1 := json.Marshal(a)
jb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(ja) == string(jb)
} }
// OrRule is one additional search OR'd with the award's primary matching rule. // OrRule is one additional search OR'd with the award's primary matching rule.
@@ -104,26 +152,124 @@ type OrRule struct {
Prefix string `json:"prefix,omitempty"` // prepended to each found reference Prefix string `json:"prefix,omitempty"` // prepended to each found reference
} }
// Defaults are the built-in awards seeded on first run (then user-editable). // catalogFS holds the built-in award definitions as DATA, not Go code.
func Defaults() []Def { //
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter // An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
// "electronically verified" tier: LoTW only — a paper QSL confirms but does // recompile and a release for every new one, which is absurd for something that
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program // changes far more often than the engine that reads it. They now live one JSON per
// accepts it (WAC). // award in catalog/, embedded in the binary: adding an award is adding a file.
lq := []string{"lotw", "qsl"} //
lo := []string{"lotw"} // This is the SEED only. Once a user has awards in their database, that database
return []Def{ // is the source of truth — the catalog never overwrites their edits behind their
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true}, // back.
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true}, //
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true}, //go:embed catalog/*.json
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true}, var catalogFS embed.FS
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true}, // CatalogEntry is one award in the catalog: its definition AND its reference list.
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true}, //
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true}, // The references are the point. An award's definition is a few lines; what makes
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true}, // WAPC worth anything is its 34 provinces and the city regexes attached to them.
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true}, // A catalog that shipped definitions only would hand every user an empty shell —
// which is exactly the trap this design is meant to avoid.
//
// References stay as raw JSON so this package (the matching ENGINE) never has to
// import the reference-store package. The caller decodes them.
type CatalogEntry struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
} }
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
//
// {"def": {...}, "references": [...]} — a catalog entry
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
//
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
// can be dropped straight into catalog/ and shipped to everyone, with no
// conversion step. That is the whole loop — create an award, export it, drop it in,
// everybody has it.
type catalogFile struct {
Def Def `json:"def"`
References json.RawMessage `json:"references,omitempty"`
Awards []CatalogEntry `json:"awards,omitempty"`
// A bare Def (the original catalog shape) is still read via the fields above
// being empty and Code being set at the top level.
Code string `json:"code,omitempty"`
}
// Catalog returns every built-in award: definition + references, sorted by code.
//
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
// how a user's award list quietly reorders itself on a rebuild.
func Catalog() []CatalogEntry {
entries, err := catalogFS.ReadDir("catalog")
if err != nil {
return nil // embedded: can only fail if the build is broken
}
out := make([]CatalogEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
b, err := catalogFS.ReadFile("catalog/" + e.Name())
if err != nil {
continue
}
var f catalogFile
if err := json.Unmarshal(b, &f); err != nil {
// A malformed file must not take the other awards down with it.
continue
}
switch {
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
for _, a := range f.Awards {
if strings.TrimSpace(a.Def.Code) != "" {
out = append(out, a)
}
}
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
case strings.TrimSpace(f.Code) != "": // a bare Def
var d Def
if err := json.Unmarshal(b, &d); err == nil {
out = append(out, CatalogEntry{Def: d})
}
}
}
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
// trusting the flag in the file: you drop in an award you exported, its JSON
// says builtin:false (you wrote it, it wasn't built-in then), and it would
// quietly miss every future catalog correction. Making the author remember to
// flip a flag is exactly the kind of step nobody can guess.
for i := range out {
out[i].Def.Builtin = true
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
return out
}
// Defaults are the built-in award definitions seeded on first run.
func Defaults() []Def {
cat := Catalog()
out := make([]Def, 0, len(cat))
for _, e := range cat {
out = append(out, e.Def)
}
return out
}
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
// any. Awards whose list is seeded from code (DXCC entities, French departments)
// or fetched online (POTA/SOTA/WWFF) carry none.
func CatalogRefs(code string) (json.RawMessage, bool) {
code = strings.ToUpper(strings.TrimSpace(code))
for _, e := range Catalog() {
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
return e.References, true
}
}
return nil, false
} }
// Migrate upgrades award definitions saved before the richer model existed. // Migrate upgrades award definitions saved before the richer model existed.
@@ -252,6 +398,21 @@ type RefMeta struct {
} }
// NewRefList builds the engine's reference view from (code, meta) pairs. // NewRefList builds the engine's reference view from (code, meta) pairs.
// compileAwardRE compiles an award / reference regex. Award patterns are run
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
// default. An author who set their own flag group (e.g. "(?s)") is respected.
func compileAwardRE(p string) (*regexp.Regexp, error) {
p = strings.TrimSpace(p)
if p == "" {
return nil, errors.New("empty pattern")
}
if !strings.HasPrefix(p, "(?") {
p = "(?i)" + p
}
return regexp.Compile(p)
}
func NewRefList(metas []RefMeta) refList { func NewRefList(metas []RefMeta) refList {
rl := refList{byCode: make(map[string]RefMeta, len(metas))} rl := refList{byCode: make(map[string]RefMeta, len(metas))}
for _, m := range metas { for _, m := range metas {
@@ -260,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
continue continue
} }
if p := strings.TrimSpace(m.Pattern); p != "" { if p := strings.TrimSpace(m.Pattern); p != "" {
if re, err := regexp.Compile(p); err == nil { if re, err := compileAwardRE(p); err == nil {
m.re = re m.re = re
rl.withPattern = append(rl.withPattern, code) rl.withPattern = append(rl.withPattern, code)
} }
@@ -292,7 +453,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
perr := make([]string, len(defs)) perr := make([]string, len(defs))
for i := range defs { for i := range defs {
if p := strings.TrimSpace(defs[i].Pattern); p != "" { if p := strings.TrimSpace(defs[i].Pattern); p != "" {
re, err := regexp.Compile(p) re, err := compileAwardRE(p)
if err != nil { if err != nil {
perr[i] = "bad pattern: " + err.Error() perr[i] = "bad pattern: " + err.Error()
} else { } else {
@@ -427,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
} }
var re *regexp.Regexp var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" { if p := strings.TrimSpace(d.Pattern); p != "" {
if c, err := regexp.Compile(p); err == nil { if c, err := compileAwardRE(p); err == nil {
re = c re = c
} else { } else {
return nil return nil
} }
} }
rl := NewRefList(metas) rl := NewRefList(metas)
found := candidates(&d, re, q, rl, len(metas) > 0) return candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
} }
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO, // ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
@@ -479,30 +630,6 @@ func manualRefs(q *qso.QSO, code string) []string {
return out return out
} }
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
}
// Confirmed reports whether a QSO satisfies any of the given confirmation // Confirmed reports whether a QSO satisfies any of the given confirmation
// sources (lotw|qsl|eqsl). Exported for the statistics view. // sources (lotw|qsl|eqsl). Exported for the statistics view.
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) } func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
@@ -560,6 +687,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
found = append(found, nc.code) found = append(found, nc.code)
} }
} }
// A reference may also declare its own regex to broaden matching beyond
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
// those too — the name match and the pattern are OR'd.
for _, code := range rl.withPattern {
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
found = append(found, code)
}
}
case predefined && !exact: case predefined && !exact:
// "Search reference inside the field": look up each token of the field in // "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few // the list — O(tokens), not O(all references) — plus test the few
@@ -588,32 +723,207 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
return found return found
} }
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
//
// An award that silently matches nothing is the hardest kind of bug to see: the
// UI shows an empty column and the operator has no way to tell whether the QSO is
// out of scope, the field is empty, the rule looked in the wrong place, or the
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
// reports every step it took.
// Rejected is a candidate a rule produced that did not survive.
type Rejected struct {
Candidate string `json:"candidate"`
Reason string `json:"reason"`
}
// Step is one matching rule as it actually ran.
type Step struct {
Rule string `json:"rule"` // "primary", "OR 1", …
Field string `json:"field"` // the QSO field it scanned
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
Exact bool `json:"exact,omitempty"` // whole field IS the reference
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
FieldValue string `json:"field_value,omitempty"` // what the field actually held
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
Kept []string `json:"kept,omitempty"` // what survived the reference list
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
}
// Explanation is the full account of one QSO against one award.
type Explanation struct {
Code string `json:"code"`
InScope bool `json:"in_scope"`
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
Predefined bool `json:"predefined"` // matches are validated against a reference list
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
// through the SAME code path as Compute — not a re-implementation — so what it
// shows is what actually happens.
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
rl := NewRefList(metas)
ex.Predefined = len(metas) > 0 && !d.Dynamic
ex.RefCount = len(metas)
var why string
if !inScopeWhy(&d, q, &why) {
ex.ScopeError = why
return ex // out of scope: no rule ever runs, and saying so IS the answer
}
ex.InScope = true
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
Error: "bad regex: " + err.Error()})
return ex
}
re = c
}
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
return ex
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string { func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
return candidatesTrace(d, re, q, rl, hasList, nil)
}
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
// passes it) each rule records what it scanned, what it produced and what was
// rejected. The tracing MUST live inside the real matcher rather than in a
// parallel "explain" implementation — a trace that can drift from the code it
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
predefined := hasList && !d.Dynamic predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches. // run executes one rule and, when tracing, records it.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined) run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
kept := keepRefs(predefined, rl, raw)
if ex != nil {
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
Candidates: raw, Kept: kept}
keptSet := map[string]struct{}{}
for _, k := range kept {
keptSet[k] = struct{}{}
}
for _, c := range raw {
n := normalizeRef(c)
if _, ok := keptSet[n]; ok {
continue
}
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
}
ex.Steps = append(ex.Steps, s)
}
return kept
}
// Primary search first; the OR rules are ordered FALLBACKS — try the next
// only while nothing has matched yet, and stop at the first that yields a
// reference (short-circuit). So a province already found by NAME isn't also
// re-derived, possibly differently, from a later city-regex rule.
//
// The short-circuit tests what a rule REALLY yielded — the references that
// survive the predefined list — not its raw candidates. A rule can always
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
// Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
for i := range d.OrRules { for i := range d.OrRules {
r := &d.OrRules[i] r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1)
if len(found) > 0 {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
Pattern: r.Pattern, Skipped: true})
}
continue // an earlier rule already matched — fallbacks short-circuit
}
var rre *regexp.Regexp var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" { if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p) c, err := compileAwardRE(p)
if err != nil { if err != nil {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
Error: "bad regex: " + err.Error()})
}
continue // skip a rule with a bad regex rather than failing the award continue // skip a rule with a bad regex rather than failing the award
} }
rre = c rre = c
} }
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...) found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
} }
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
// the operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
// a contact whose address doesn't spell it out needs the province picked by
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
// awards panel and the per-QSO refs editor — honours overrides too. For a
// predefined award the ref is still validated against the list below.
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
out := dedupe(found)
if ex != nil {
ex.Result = out
}
return out
}
// rejection explains why a candidate the operator can SEE in the trace did not
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code string) Rejected {
switch {
case code == "":
return Rejected{Candidate: code, Reason: "empty"}
case !predefined:
return Rejected{Candidate: code, Reason: "duplicate"}
}
m, ok := rl.byCode[code]
if !ok {
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
}
if !m.Valid {
return Rejected{Candidate: code, Reason: "listed but disabled"}
}
return Rejected{Candidate: code, Reason: "duplicate"}
}
// keepRefs reduces a rule's raw candidates to the references that actually count.
// For a predefined award that means the codes on the list, enabled. The
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
// so we do NOT additionally require the QSO's entity to match the reference's own
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
// 291). Per-reference DXCC stays metadata for the picker.
func keepRefs(predefined bool, rl refList, found []string) []string {
if !predefined { if !predefined {
return dedupe(found) out := make([]string, 0, len(found))
for _, c := range found {
if c = normalizeRef(c); c != "" {
out = append(out, c)
}
}
return dedupe(out)
} }
// Enforce the predefined list: keep only listed, valid references. The
// award-level DXCCFilter already scopes which QSOs are considered (see
// inScope), so we do NOT additionally require the QSO's entity to match the
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
var out []string var out []string
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, c := range found { for _, c := range found {
@@ -754,24 +1064,37 @@ func natLess(a, b string) bool {
// inScope reports whether a QSO falls within an award's scope (DXCC entity, // inScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission category, validity dates). // bands, modes, emission category, validity dates).
func inScope(d *Def, q *qso.QSO) bool { func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
// which runs this for every QSO × every award, passes nil and pays nothing).
// Keeping both behind one function is the point: a scope check that disagrees with
// the scope check it explains would be worse than no explanation at all.
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
fail := func(format string, args ...any) bool {
if why != nil {
*why = fmt.Sprintf(format, args...)
}
return false return false
} }
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
}
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) { if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
return false return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
} }
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) { if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
return false return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
} }
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) { if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
return false return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
} }
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom { if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
return false return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
} }
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo { if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
return false return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
} }
return true return true
} }
+446
View File
@@ -1,8 +1,11 @@
package award package award
import ( import (
"encoding/json"
"sort" "sort"
"strings"
"testing" "testing"
"time"
"hamlog/internal/qso" "hamlog/internal/qso"
) )
@@ -128,6 +131,146 @@ func TestComputeMatchByDescription(t *testing.T) {
} }
} }
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
// rules; when the primary finds nothing, the first OR rule that hits wins and
// later rules are skipped. DDFM-style: a French department from the NOTE first,
// else captured from a postal code in the address (Prefix "D" → "D74").
func TestComputeOrRuleFallback(t *testing.T) {
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
DXCCFilter: []int{227}, Valid: true,
OrRules: []OrRule{
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
},
}
refMetas := map[string][]RefMeta{"DDFM": {
{Code: "D74", Name: "Haute-Savoie", Valid: true},
{Code: "D75", Name: "Paris", Valid: true},
}}
worked := func(q qso.QSO) []string {
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
var out []string
for _, rf := range r.Refs {
if rf.Worked {
out = append(out, rf.Ref)
}
}
return out
}
// Primary (note "D74") matches → the postal OR rule is skipped, so the
// address's 75001 does NOT also add D75. Exactly the user's ask: first
// condition wins, no fall-through.
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
}
// No note dept → fall through to the OR rule → postal 74140 → D74.
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
t.Errorf("OR fallback: got %v, want [D74]", got)
}
}
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
// rule with an empty rule-pattern triggers the per-reference regexes.
func TestComputeProvinceCascade(t *testing.T) {
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{318}, Valid: true,
OrRules: []OrRule{
{Field: "address", MatchBy: "description"},
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
},
}
refMetas := map[string][]RefMeta{"WAPC": {
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
}}
worked := func(q qso.QSO) []string {
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
var out []string
for _, rf := range r.Refs {
if rf.Worked {
out = append(out, rf.Ref)
}
}
return out
}
cases := []struct {
name, qth, addr string
want string
}{
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
}
for _, c := range cases {
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
if len(got) != 1 || got[0] != c.want {
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
}
}
}
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
// no QSO field) sticks after the operator picks the province by hand. The engine
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
// the ref exists solely as the override.
func TestComputeManualOverride(t *testing.T) {
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
// Address doesn't name the province → only the override tags it.
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
}
refMetas := map[string][]RefMeta{"WAPC": {
{Code: "JS", Name: "Jiangsu", Valid: true},
{Code: "GD", Name: "Guangdong", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 1 {
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
}
var worked []string
for _, rf := range r.Refs {
if rf.Worked {
worked = append(worked, rf.Ref)
}
}
if len(worked) != 1 || worked[0] != "JS" {
t.Errorf("worked refs = %v, want [JS]", worked)
}
// An override for a ref NOT in the predefined list is rejected (no typo refs).
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
}
}
// A description-match award must also honour a REFERENCE's own regex (used to
// broaden matching past the plain name) AND match case-insensitively — a log
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
func TestComputeDescriptionRefPattern(t *testing.T) {
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
}
refMetas := map[string][]RefMeta{"WAJA": {
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
{Code: "27", Name: "Osaka", Valid: true},
{Code: "01", Name: "Hokkaido", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 2 {
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
}
}
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid // VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the // line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
// 4-char prefix of GRIDSQUARE. // 4-char prefix of GRIDSQUARE.
@@ -146,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
} }
} }
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
// fails on the count, the catalog file is wrong, not the test.
func TestCatalogFFMA(t *testing.T) {
raw, ok := CatalogRefs("FFMA")
if !ok {
t.Fatal("FFMA has no reference list in the embedded catalog")
}
var refs []struct {
Code string `json:"code"`
DXCC int `json:"dxcc"`
Valid bool `json:"valid"`
}
if err := json.Unmarshal(raw, &refs); err != nil {
t.Fatalf("FFMA references: %v", err)
}
if len(refs) != 488 {
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
}
var def Def
metas := make([]RefMeta, 0, len(refs))
for _, r := range refs {
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
// water. Pinning a reference to a DXCC entity would reject those contacts.
if r.DXCC != 0 {
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
}
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
}
for _, d := range Defaults() {
if d.Code == "FFMA" {
def = d
}
}
if def.Total != 488 || def.Field != "grid4" {
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
}
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
qsos := []qso.QSO{
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
var got []string
for _, rf := range r.Refs {
if rf.Worked {
got = append(got, rf.Ref)
}
}
sort.Strings(got)
want := []string{"EM00", "FN25", "FN31", "FN32"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("FFMA worked = %v, want %v", got, want)
}
if r.Worked != len(want) || r.Total != 488 {
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
}
}
// WAIP: the primary rule takes the WHOLE address as the province code (exact
// match), which can never be one — but it does produce a raw candidate. The OR
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
// and it must still run: a rule that yields nothing VALID is not a hit.
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
def := Def{
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
Confirm: []string{"lotw", "qsl"},
}
metas := []RefMeta{
{Code: "BG", Name: "Bergamo", Valid: true},
{Code: "MI", Name: "Milano", Valid: true},
}
qsos := []qso.QSO{
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
if r.Worked != 1 {
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
}
for _, rf := range r.Refs {
if rf.Ref == "BG" && rf.Worked {
return
}
}
t.Errorf("BG not worked; refs = %v", refCodes(r))
}
// Explain must account for the WAIP case exactly: the primary rule produces a
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
func TestExplainAccountsForEveryRule(t *testing.T) {
def := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
Confirm: []string{"lotw"},
}
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
ex := Explain(def, metas, q)
if !ex.InScope || len(ex.Steps) != 2 {
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
}
// The primary rule LOOKS like it found something. Saying only "no match" here is
// what cost hours: the trace has to show the candidate and why it lost.
p := ex.Steps[0]
if len(p.Candidates) == 0 {
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
}
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
}
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
}
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
}
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
t.Errorf("result = %v, want [BG]", ex.Result)
}
// A trace that disagrees with the matcher is worse than none: it is believed.
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
}
}
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
if ex.InScope {
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
}
if !strings.Contains(ex.ScopeError, "2m") {
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
}
if len(ex.Steps) != 0 {
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
}
}
func refCodes(r Result) []string { func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs)) out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs { for _, rf := range r.Refs {
@@ -238,3 +535,152 @@ func TestComputePredefinedList(t *testing.T) {
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r)) t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
} }
} }
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
// A refactor is only a refactor if the behaviour is identical, so pin down what
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
// disable an award, and nobody would notice until a QSO stopped counting.
func TestCatalogDefaults(t *testing.T) {
defs := Defaults()
byCode := map[string]Def{}
for _, d := range defs {
byCode[d.Code] = d
}
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
// assert the ten originals are still there, never that the count is exactly ten:
// a test that fails the moment you add an award is a test that teaches you to
// ignore it.
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
if len(defs) < len(want) {
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
}
for _, c := range want {
d, ok := byCode[c]
if !ok {
t.Errorf("%s missing from the embedded catalog", c)
continue
}
// Valid=false would hide the award entirely; Builtin=false would let a
// "reset to defaults" delete it. Both are silent failures.
if !d.Valid || !d.Builtin {
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
}
if d.Type == "" || d.Field == "" {
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
}
if len(d.Confirm) == 0 {
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
}
}
// Spot-check the two that carry real matching logic, since a mangled escape in
// JSON is exactly the failure this test exists to catch.
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
}
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
t.Errorf("DDFM pattern does not compile: %v", err)
}
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
}
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
}
if !byCode["WPX"].Dynamic {
t.Error("WPX must be dynamic — its references aren't a fixed list")
}
// Deterministic order: an embed walk that reorders would shuffle the user's list.
for i := 1; i < len(defs); i++ {
if defs[i-1].Code > defs[i].Code {
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
}
}
}
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
// straight into catalog/ and shipped to everyone — no conversion step. So the
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
// because a WAPC without its provinces and their city regexes is an empty shell.
func TestCatalogAcceptsExportedBundle(t *testing.T) {
// Exactly what ExportAward writes.
exported := []byte(`{
"version": 1,
"exported_at": "2026-07-13T10:00:00Z",
"awards": [
{
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
"type":"QSOFIELDS","field":"address","match_by":"description",
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
"references": [
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
{"code":"ZJ","name":"Zhejiang","valid":true}
]
}
]
}`)
var f catalogFile
if err := json.Unmarshal(exported, &f); err != nil {
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
}
if len(f.Awards) != 1 {
t.Fatalf("got %d awards, want 1", len(f.Awards))
}
e := f.Awards[0]
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
t.Errorf("definition lost in the round-trip: %+v", e.Def)
}
if len(e.References) == 0 {
t.Fatal("references dropped — this is the whole value of sharing an award")
}
// The per-reference regex must survive: it is what makes the award actually work.
var refs []struct {
Code string `json:"code"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(e.References, &refs); err != nil {
t.Fatalf("references not decodable: %v", err)
}
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
t.Errorf("reference regex did not survive: %+v", refs)
}
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
t.Errorf("the shipped regex does not compile: %v", err)
}
}
// A single malformed file in the catalog must not take the other awards down with
// it — one bad JSON should cost you one award, not all of them.
func TestCatalogSurvivesOneBadFile(t *testing.T) {
if len(Catalog()) < 10 {
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
}
var f catalogFile
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
t.Error("expected a parse error on malformed JSON")
}
// Catalog() skips unparseable files rather than returning nil, so the others
// still load. (Verified structurally: the loader `continue`s on error.)
}
// Anything OpsLog SHIPS is built-in, whatever the file says.
//
// You create an award, export it (its JSON records builtin:false — it wasn't
// built-in when you wrote it), then drop that same file into the catalog to ship
// it. If we trusted the flag, the award would go out to everyone marked "not
// built-in" and would then silently miss every future catalog correction. Making
// the author remember to flip a flag first is a step nobody can guess — so the
// catalog derives it instead.
func TestCatalogForcesBuiltin(t *testing.T) {
for _, e := range Catalog() {
if !e.Def.Builtin {
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
}
}
// The realistic case: a user-authored award whose JSON says builtin:false.
if len(Catalog()) == 0 {
t.Fatal("empty catalog")
}
}
+107
View File
@@ -0,0 +1,107 @@
package award
import (
"encoding/json"
"fmt"
"testing"
"time"
"hamlog/internal/qso"
)
// A synthetic log with the shape a real one has: a spread of bands, modes,
// entities, and the free-text fields the harder awards actually scan.
func benchLog(n int) []qso.QSO {
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
out := make([]qso.QSO, n)
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range out {
e := entities[i%len(entities)]
out[i] = qso.QSO{
ID: int64(i + 1),
Callsign: fmt.Sprintf("I%dABC", i%10),
Band: bands[i%len(bands)],
Mode: modes[i%len(modes)],
DXCC: &e,
QSODate: base.Add(time.Duration(i) * time.Hour),
QTH: towns[i%len(towns)],
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
Grid: "JN45AA",
State: "CA",
LOTWRcvd: "Y",
}
}
return out
}
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
// that always produces a doomed candidate, then a fallback chain that tokenises a
// free-text field. If anything is slow, it is this.
func benchWAIP() (Def, []RefMeta) {
d := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{
{Field: "qth", MatchBy: "description"},
{Field: "qth", MatchBy: "code"},
{Field: "address", MatchBy: "code"},
},
DXCCFilter: []int{248},
Confirm: []string{"lotw", "qsl"},
}
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
metas := make([]RefMeta, 0, len(codes))
for _, c := range codes {
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
}
return d, metas
}
// One award, whole log — what GetAward(code) does when the Awards panel opens.
func BenchmarkComputeOneAward(b *testing.B) {
d, metas := benchWAIP()
for _, n := range []int{10_000, 50_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
}
})
}
}
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
func BenchmarkComputeWholeCatalog(b *testing.B) {
defs := Defaults()
metas := map[string][]RefMeta{}
wd, wm := benchWAIP()
defs = append(defs, wd)
metas["WAIP"] = wm
for _, c := range Catalog() {
if raw, ok := CatalogRefs(c.Def.Code); ok {
var refs []struct {
Code string `json:"code"`
Name string `json:"name"`
Valid bool `json:"valid"`
}
if json.Unmarshal(raw, &refs) == nil {
m := make([]RefMeta, 0, len(refs))
for _, r := range refs {
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
}
metas[c.Def.Code] = m
}
}
}
for _, n := range []int{10_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute(defs, log, metas, nil)
}
})
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"def": {
"code": "DDFM",
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"dxcc_filter": [
227
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 96,
"builtin": true
}
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"def": {
"code": "DXCC",
"name": "DX Century Club",
"valid": true,
"protected": true,
"type": "DXCC",
"field": "dxcc",
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 340,
"builtin": true
}
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"def": {
"code": "IOTA",
"name": "Islands On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "iota",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "POTA",
"name": "Parks On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "pota_ref",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+156
View File
@@ -0,0 +1,156 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "RAC",
"name": "RAC Canadian Provinces",
"description": "RAC Canadian Provinces",
"valid": true,
"protected": true,
"url": "https://www.rac.ca/canadaward/",
"valid_from": "1977-01-07",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
1
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AB",
"name": "Alberta",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "BC",
"name": "British Columbia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Manitoba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NB",
"name": "New Brunswick",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NL",
"name": "Newfoundland and Labrador",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NS",
"name": "Nova Scotia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NT",
"name": "Northwest Territories",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nunavut",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "ON",
"name": "Ontario",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Prince Edward Island",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "QC",
"name": "Quebec",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "SK",
"name": "Saskatchewan",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "YT",
"name": "Yukon",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "SOTA",
"name": "Summits On The Air",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "sota_ref",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "VUCC",
"name": "VHF/UHF Century Club",
"description": "VHF/UHF Century Club",
"valid": true,
"protected": true,
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
"valid_from": "1970-01-01",
"valid_to": "9999-01-30",
"type": "QSOFIELDS",
"field": "grid4",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"valid_bands": [
"6m",
"4m",
"2m",
"70cm",
"23cm",
"13cm",
"1.25m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": null
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"def": {
"code": "WAC",
"name": "Worked All Continents",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "cont",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl",
"eqsl"
],
"validate": [
"lotw"
],
"total": 6,
"builtin": true
}
}
+929
View File
@@ -0,0 +1,929 @@
{
"version": 1,
"exported_at": "2026-07-13T21:57:23Z",
"awards": [
{
"def": {
"code": "WAIP",
"name": "ARI Worked All Italian Provinces",
"valid": true,
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"type": "REFERENCE",
"field": "qth",
"match_by": "code",
"exact_match": true,
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "code"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
248
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": false
},
"references": [
{
"code": "AG",
"name": "Agrigento",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "AL",
"name": "Alessandria",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AN",
"name": "Ancona",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AO",
"name": "Aosta",
"dxcc": 248,
"group": "Val d'Aosta",
"subgrp": "",
"valid": true
},
{
"code": "AP",
"name": "Ascoli Piceno",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AQ",
"name": "L'Aquila",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "AR",
"name": "Arezzo",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "AT",
"name": "Asti",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AV",
"name": "Avellino",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BA",
"name": "Bari",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BG",
"name": "Bergamo",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BI",
"name": "Biella",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "BL",
"name": "Belluno",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "BN",
"name": "Benevento",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BO",
"name": "Bologna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "BR",
"name": "Brindisi",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BS",
"name": "Brescia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BT",
"name": "Barletta-Andria-Trani",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BZ",
"name": "Bolzano/Bozen",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "CA",
"name": "Cagliari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CB",
"name": "Campobasso",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "CE",
"name": "Caserta",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "CH",
"name": "Chieti",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "CI",
"name": "Carbonia-Iglesias",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CL",
"name": "Caltanissetta",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CN",
"name": "Cuneo",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "CO",
"name": "Como",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CR",
"name": "Cremona",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CS",
"name": "Cosenza",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "CT",
"name": "Catania",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CZ",
"name": "Catanzaro",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "EN",
"name": "Enna",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "FC",
"name": "Forlì-Cesena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FE",
"name": "Ferrara",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FG",
"name": "Foggia",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "FI",
"name": "Firenze",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "FM",
"name": "Fermo",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "FR",
"name": "Frosinone",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "GE",
"name": "Genova",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "GO",
"name": "Gorizia",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "GR",
"name": "Grosseto",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "IM",
"name": "Imperia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "IS",
"name": "Isernia",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "KR",
"name": "Crotone",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "LC",
"name": "Lecco",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LE",
"name": "Lecce",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "LI",
"name": "Livorno",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "LO",
"name": "Lodi",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LT",
"name": "Latina",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "LU",
"name": "Lucca",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Monza e Brianza",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MC",
"name": "Macerata",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "ME",
"name": "Messina",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "MI",
"name": "Milano",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MN",
"name": "Mantova",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MO",
"name": "Modena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "MS",
"name": "Massa-Carrara",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MT",
"name": "Matera",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "NA",
"name": "Napoli",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "NO",
"name": "Novara",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nuoro",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OG",
"name": "Ogliastra",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OR",
"name": "Oristano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OT",
"name": "Olbia-Tempio",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "PA",
"name": "Palermo",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "PC",
"name": "Piacenza",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PD",
"name": "Padova",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Pescara",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "PG",
"name": "Perugia",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "PI",
"name": "Pisa",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PN",
"name": "Pordenone",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "PO",
"name": "Prato",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PR",
"name": "Parma",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PT",
"name": "Pistoia",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PU",
"name": "Pesaro-Urbino",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "PV",
"name": "Pavia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "PZ",
"name": "Potenza",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "RA",
"name": "Ravenna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RC",
"name": "Reggio Calabria",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "RE",
"name": "Reggio Emilia",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RG",
"name": "Ragusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "RI",
"name": "Rieti",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RM",
"name": "Roma",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RN",
"name": "Rimini",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RO",
"name": "Rovigo",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "SA",
"name": "Salerno",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "SI",
"name": "Siena",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "SO",
"name": "Sondrio",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "SP",
"name": "La Spezia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "SR",
"name": "Siracusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "SS",
"name": "Sassari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "SV",
"name": "Savona",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "TA",
"name": "Taranto",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "TE",
"name": "Teramo",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "TN",
"name": "Trento",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "TO",
"name": "Torino",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "TP",
"name": "Trapani",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "TR",
"name": "Terni",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "TS",
"name": "Trieste",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "TV",
"name": "Treviso",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "UD",
"name": "Udine",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "VA",
"name": "Varese",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "VB",
"name": "Verbano-Cusio-Ossola",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VC",
"name": "Vercelli",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VE",
"name": "Venezia",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VI",
"name": "Vicenza",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VR",
"name": "Verona",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VS",
"name": "Medio Campidano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "VT",
"name": "Viterbo",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "VV",
"name": "Vibo Valentia",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
}
]
}
]
}
+432
View File
@@ -0,0 +1,432 @@
{
"version": 1,
"exported_at": "2026-07-13T15:44:38Z",
"awards": [
{
"def": {
"code": "WAJA",
"name": "Worked All Japanese Prefectures",
"description": "Worked All Japanese Prefectures",
"valid": true,
"protected": true,
"valid_from": "1970-01-01",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "qth",
"match_by": "description",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "pattern"
}
],
"dxcc_filter": [
339
],
"valid_bands": [
"80m",
"40m",
"20m",
"15m",
"10m",
"160m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "1",
"name": "Hokkaido",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "10",
"name": "Gunma",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "11",
"name": "Saitama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "12",
"name": "Chiba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "13",
"name": "Tokyo",
"dxcc": 0,
"group": "",
"subgrp": "",
"pattern": "\\bTok[iy]o\\b",
"valid": true
},
{
"code": "14",
"name": "Kanagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "15",
"name": "Niigata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "16",
"name": "Toyama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "17",
"name": "Ishikawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "18",
"name": "Fukui",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "19",
"name": "Yamanashi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "2",
"name": "Aomori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "20",
"name": "Nagano",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "21",
"name": "Gifu",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "22",
"name": "Shizuoka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "23",
"name": "Aichi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "24",
"name": "Mie",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "25",
"name": "Shiga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "26",
"name": "Kyoto",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "27",
"name": "Osaka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "28",
"name": "Hyogo",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "29",
"name": "Nara",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "3",
"name": "Iwate",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "30",
"name": "Wakayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "31",
"name": "Tottori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "32",
"name": "Shimane",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "33",
"name": "Okayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "34",
"name": "Hiroshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "35",
"name": "Yamaguchi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "36",
"name": "Tokushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "37",
"name": "Kagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "38",
"name": "Ehime",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "39",
"name": "Kochi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "4",
"name": "Miyagi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "40",
"name": "Fukuoka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "41",
"name": "Saga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "42",
"name": "Nagasaki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "43",
"name": "Kumamoto",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "44",
"name": "Oita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "45",
"name": "Miyazaki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "46",
"name": "Kagoshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "47",
"name": "Okinawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "5",
"name": "Akita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "6",
"name": "Yamagata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "7",
"name": "Fukushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "8",
"name": "Ibaraki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "9",
"name": "Tochigi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+335
View File
@@ -0,0 +1,335 @@
{
"version": 1,
"exported_at": "2026-07-13T15:43:49Z",
"awards": [
{
"def": {
"code": "WAPC",
"name": "Worked All Provinces of China",
"description": "Worked All Provinces of China",
"valid": true,
"protected": true,
"url": "http://www.mulandxc.com/index/wapc_medal_app",
"valid_from": "2012-04-21",
"valid_to": "9999-01-31",
"type": "QSOFIELDS",
"field": "address",
"match_by": "description",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "qth",
"match_by": "pattern"
}
],
"dxcc_filter": [
318,
152,
321
],
"valid_bands": [
"80m",
"40m",
"20m",
"15m",
"10m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AH",
"name": "Anhui",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "BJ",
"name": "Beijing",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "CQ",
"name": "Chongqing",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "FJ",
"name": "Fujian",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GD",
"name": "Guangdong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GS",
"name": "Gansu",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GX",
"name": "Guangxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "GZ",
"name": "Guizhou",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HA",
"name": "Henan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HB",
"name": "Hubei",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HE",
"name": "Hebei",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HI",
"name": "Hainan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HK",
"name": "Hong Kong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HL",
"name": "Heilongjiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "HN",
"name": "Hunan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "JL",
"name": "Jilin",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "JS",
"name": "Jiangsu",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"pattern": "\\bJiangyin\\b",
"valid": true
},
{
"code": "JX",
"name": "Jiangxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "LN",
"name": "Liaoning",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "MO",
"name": "Macau",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"pattern": "\\bMaca[uo]\\b",
"valid": true
},
{
"code": "NM",
"name": "NeiMongol",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "NX",
"name": "Ningxia",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "QH",
"name": "Qinghai",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SC",
"name": "Sichuan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SD",
"name": "Shandong",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SH",
"name": "Shanghai",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SN",
"name": "Shaanxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "SX",
"name": "Shanxi",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "TJ",
"name": "Tianjin",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "TW",
"name": "Taiwan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "XJ",
"name": "Xinjiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "XZ",
"name": "Xizang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "YN",
"name": "Yunnan",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
},
{
"code": "ZJ",
"name": "Zhejiang",
"dxcc": 318,
"group": "China Provinces",
"subgrp": "",
"valid": true
}
]
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"def": {
"code": "WAS",
"name": "Worked All States",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": [
291,
110,
6
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 50,
"builtin": true
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"def": {
"code": "WAZ",
"name": "Worked All Zones (CQ)",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "cqz",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 40,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "WPX",
"name": "Worked All Prefixes (CQ WPX)",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "prefix",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"def": {
"code": "WWFF",
"name": "World Wide Flora & Fauna",
"valid": true,
"protected": true,
"type": "REFERENCE",
"field": "wwff",
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 0,
"builtin": true
}
}
+24
View File
@@ -210,6 +210,7 @@ type SpotInfo struct {
Mode string Mode string
Color string Color string
Comment string Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
} }
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio // Spotter is an OPTIONAL backend capability: show cluster spots on the radio
@@ -275,6 +276,11 @@ type FlexTXState struct {
Mon bool `json:"mon"` Mon bool `json:"mon"`
MonLevel int `json:"mon_level"` MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"` MicLevel int `json:"mic_level"`
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
MicProfile string `json:"mic_profile,omitempty"`
MicProfiles []string `json:"mic_profiles,omitempty"`
ATUStatus string `json:"atu_status,omitempty"` ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"` ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls. // Active RX slice DSP controls.
@@ -296,6 +302,15 @@ type FlexTXState struct {
NRLevel int `json:"nr_level"` NRLevel int `json:"nr_level"`
ANF bool `json:"anf"` ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"` ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"`
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
// moving the slice. The offset survives the switch being turned off, so
// re-enabling restores it, exactly like the radio's own knob.
RIT bool `json:"rit"`
RITFreq int `json:"rit_freq"`
XIT bool `json:"xit"`
XITFreq int `json:"xit_freq"`
// CW / mode-specific controls. // CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…) Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"` CWSpeed int `json:"cw_speed"`
@@ -322,6 +337,7 @@ type FlexMeter struct {
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP… Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP… Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
Value float64 `json:"value"` Value float64 `json:"value"`
Lo float64 `json:"lo"` Lo float64 `json:"lo"`
Hi float64 `json:"hi"` Hi float64 `json:"hi"`
@@ -344,6 +360,8 @@ type FlexController interface {
SetMon(bool) error SetMon(bool) error
SetMonLevel(int) error SetMonLevel(int) error
SetMic(int) error SetMic(int) error
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
SetMicProfile(string) error // load a SmartSDR mic profile by name
ATUStart() error ATUStart() error
ATUBypass() error ATUBypass() error
SetATUMemories(bool) error SetATUMemories(bool) error
@@ -365,6 +383,12 @@ type FlexController interface {
SetANFLevel(int) error SetANFLevel(int) error
SetAPF(bool) error SetAPF(bool) error
SetAPFLevel(int) error SetAPFLevel(int) error
SetWNB(bool) error
SetWNBLevel(int) error
SetRIT(bool) error
SetRITFreq(int) error
SetXIT(bool) error
SetXITFreq(int) error
// CW keyer + mode-specific controls. // CW keyer + mode-specific controls.
SetCWSpeed(int) error SetCWSpeed(int) error
SetCWPitch(int) error SetCWPitch(int) error
+2
View File
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
return "IC-7300" return "IC-7300"
case 0x98: case 0x98:
return "IC-7610" return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2: case 0xA2:
return "IC-9700" return "IC-9700"
case 0xA4: case 0xA4:
+264 -24
View File
@@ -38,6 +38,8 @@ type Flex struct {
slices map[int]*flexSlice slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
micProfile string // currently loaded mic profile
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly) txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
lastStateSig string // last logged derived-state signature (log only on change) lastStateSig string // last logged derived-state signature (log only on change)
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power). boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
@@ -58,6 +60,7 @@ type Flex struct {
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create) pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user // OnSpotClick is called (off the reader goroutine's hot path) when the user
@@ -85,6 +88,12 @@ type flexSlice struct {
anfLevel int anfLevel int
apf bool // CW audio peaking filter apf bool // CW audio peaking filter
apfLevel int apfLevel int
wnb bool // wideband noise blanker
wnbLevel int
rit bool // receive incremental tuning enabled
ritFreq int // RIT offset in Hz (negative = down)
xit bool // transmit incremental tuning enabled
xitFreq int // XIT offset in Hz
filterLo int // slice filter low cut (Hz) filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz) filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A) rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
@@ -108,6 +117,8 @@ type flexTX struct {
mon bool mon bool
monLevel int monLevel int
micLevel int micLevel int
filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz)
atuStatus string atuStatus string
atuMemories bool atuMemories bool
// CW keyer params (set via the top-level "cw" commands). // CW keyer params (set via the top-level "cw" commands).
@@ -133,6 +144,7 @@ type meterInfo struct {
src string // SLC (slice), TX-, COD, RAD, AMP… src string // SLC (slice), TX-, COD, RAD, AMP…
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B… name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
unit string // dbm, dbfs, swr, volts, degc, watts… unit string // dbm, dbfs, swr, volts, degc, watts…
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
lo float64 lo float64
hi float64 hi float64
} }
@@ -151,7 +163,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{ return &Flex{
host: strings.TrimSpace(host), port: port, host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{}, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
} }
@@ -209,6 +221,8 @@ func (f *Flex) Connect() error {
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state) f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default) f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
f.send("profile mic info") // request the current mic profile list + selection
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below) f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
f.startMeters(conn) // open the UDP VITA-49 stream for live meters f.startMeters(conn) // open the UDP VITA-49 stream for live meters
if f.spotsEnabled { if f.spotsEnabled {
@@ -344,6 +358,7 @@ func (f *Flex) reader(conn net.Conn) {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil { if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.spotCall[idx] = call f.spotCall[idx] = call
f.spotIdx[idx] = true f.spotIdx[idx] = true
f.spotByCall[strings.ToUpper(call)] = idx
} }
} }
// A successful "slice create" for split returns the new slice's index; // A successful "slice create" for split returns the new slice's index;
@@ -445,6 +460,12 @@ func (f *Flex) handleStatus(payload string) {
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay) f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
case "mic_level", "miclevel": case "mic_level", "miclevel":
f.tx.micLevel = atoiDefault(val, f.tx.micLevel) f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
// TX filter: the transmit STATUS reports the passband as lo/hi (the
// SET command is filter_low/filter_high — a SmartSDR quirk).
case "lo", "filter_low":
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
case "hi", "filter_high":
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -526,6 +547,35 @@ func (f *Flex) handleStatus(payload string) {
} }
f.mu.Unlock() f.mu.Unlock()
} }
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
// values are taken from the raw payload after the key. Logged once so the
// exact field names are confirmable on real hardware.
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
debugLog.Printf("Flex: profile status: %s", payload)
if i := strings.Index(payload, "list="); i >= 0 {
var profs []string
for _, p := range strings.Split(payload[i+len("list="):], "^") {
if p = strings.TrimSpace(p); p != "" {
profs = append(profs, p)
}
}
f.mu.Lock()
f.micProfiles = profs
f.mu.Unlock()
}
// The loaded profile arrives as current=<name> (some firmwares:
// selection=<name>); accept either.
for _, key := range []string{"current=", "selection="} {
if i := strings.Index(payload, key); i >= 0 {
cur := strings.TrimSpace(payload[i+len(key):])
f.mu.Lock()
f.micProfile = cur
f.mu.Unlock()
break
}
}
}
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …). // Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
if len(fields) >= 1 && fields[0] == "interlock" { if len(fields) >= 1 && fields[0] == "interlock" {
f.mu.Lock() f.mu.Lock()
@@ -596,7 +646,7 @@ func (f *Flex) handleStatus(payload string) {
// One meter per token; its fields are '#'-separated: // One meter per token; its fields are '#'-separated:
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…". // "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
num := -1 num := -1
var mi meterInfo mi := meterInfo{slc: -1}
for _, sub := range strings.Split(tok, "#") { for _, sub := range strings.Split(tok, "#") {
key, val, ok := splitKV(sub) key, val, ok := splitKV(sub)
if !ok { if !ok {
@@ -616,6 +666,12 @@ func (f *Flex) handleStatus(payload string) {
mi.src = val mi.src = val
case "nam": case "nam":
mi.name = val mi.name = val
case "num":
// For a slice (SLC) meter this field is the slice index —
// how we tell slice A's S-meter from slice B's.
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
mi.slc = v
}
case "unit", "units": case "unit", "units":
mi.unit = val mi.unit = val
case "low", "lo": case "low", "lo":
@@ -630,6 +686,7 @@ func (f *Flex) handleStatus(payload string) {
old, seen := f.meterMeta[num] old, seen := f.meterMeta[num]
if !seen { if !seen {
newIDs = append(newIDs, num) newIDs = append(newIDs, num)
old.slc = -1
} }
if mi.src != "" { if mi.src != "" {
old.src = mi.src old.src = mi.src
@@ -640,6 +697,9 @@ func (f *Flex) handleStatus(payload string) {
if mi.unit != "" { if mi.unit != "" {
old.unit = mi.unit old.unit = mi.unit
} }
if mi.slc >= 0 {
old.slc = mi.slc
}
if mi.lo != 0 { if mi.lo != 0 {
old.lo = mi.lo old.lo = mi.lo
} }
@@ -651,7 +711,7 @@ func (f *Flex) handleStatus(payload string) {
f.mu.Unlock() f.mu.Unlock()
for _, id := range newIDs { for _, id := range newIDs {
mi := f.meterMeta[id] mi := f.meterMeta[id]
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit) debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
f.subscribeMeter(id) f.subscribeMeter(id)
} }
} }
@@ -744,6 +804,18 @@ func (f *Flex) handleStatus(payload string) {
s.apf = val == "1" s.apf = val == "1"
case "apf_level": case "apf_level":
s.apfLevel = atoiDefault(val, s.apfLevel) s.apfLevel = atoiDefault(val, s.apfLevel)
case "wnb":
s.wnb = val == "1"
case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "rit_on":
s.rit = val == "1"
case "rit_freq":
s.ritFreq = atoiDefault(val, s.ritFreq)
case "xit_on":
s.xit = val == "1"
case "xit_freq":
s.xitFreq = atoiDefault(val, s.xitFreq)
case "filter_lo": case "filter_lo":
s.filterLo = atoiDefault(val, s.filterLo) s.filterLo = atoiDefault(val, s.filterLo)
case "filter_hi": case "filter_hi":
@@ -803,24 +875,44 @@ func (f *Flex) ReadState() (RigState, error) {
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in // band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu. // use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) { func (f *Flex) mainSliceLocked() (int, *flexSlice) {
best, bestS := 1<<30, (*flexSlice)(nil) // Iterate in ASCENDING index order — NEVER map-iteration order, which Go
for idx, s := range f.slices { // randomises. When two slices transiently BOTH report active=1 (e.g. an
// external controller like DXHunter activates a slice on another band while
// ours still holds active, before SmartSDR sends active=0 to the old one),
// map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably.
firstInUse := -1
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if !s.inUse { if !s.inUse {
continue continue
} }
if firstInUse < 0 {
firstInUse = idx
}
if s.active { if s.active {
return idx, s return idx, s
} }
if idx < best {
best, bestS = idx, s
} }
} if firstInUse >= 0 {
if bestS != nil { return firstInUse, f.slices[firstInUse]
return best, bestS
} }
return -1, nil return -1, nil
} }
// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
idxs := make([]int, 0, len(f.slices))
for idx := range f.slices {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
// activeSliceIndexLocked returns the slice index to send commands to (the main // activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu. // slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int { func (f *Flex) activeSliceIndexLocked() int {
@@ -841,8 +933,8 @@ func sliceLetter(idx int) string {
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil. // txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu. // Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice { func (f *Flex) txSliceLocked() *flexSlice {
for _, s := range f.slices { for _, idx := range f.sortedSliceIdxLocked() {
if s.inUse && s.tx { if s := f.slices[idx]; s.inUse && s.tx {
return s return s
} }
} }
@@ -852,9 +944,11 @@ func (f *Flex) txSliceLocked() *flexSlice {
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the // operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is // mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different // the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) — detected from the pair itself, NOT from which slice is active (the TX // freq) AND the SAME split class (both PHONE, or both CW) — detected from the
// slice often steals focus right after "slice create", which must NOT read as // pair itself, NOT from which slice is active (the TX slice often steals focus
// "no split"). A slice on another band is an independent receiver, ignored. // right after "slice create", which must NOT read as "no split"). A slice on
// another band is an independent receiver, ignored; so is a same-band slice in a
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
// Caller holds f.mu. // Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) { func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
_, main = f.mainSliceLocked() _, main = f.mainSliceLocked()
@@ -866,20 +960,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
if bt == "" { if bt == "" {
return main, nil, nil return main, nil, nil
} }
// RX = the active slice when it's a distinct same-band slice, else the first // Split only applies to PHONE/CW; a data-mode TX slice never splits.
// other in-use same-band slice. ct := flexSplitClass(txS.mode)
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt { if ct == "" {
return main, nil, nil
}
// A split partner is an in-use, same-band slice at a different freq in the
// SAME split class (so SSB + FT8 on one band is NOT a split).
sameSplit := func(s *flexSlice) bool {
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
}
// RX = the active slice when it qualifies, else the first other qualifying
// same-band slice.
if sameSplit(main) {
rx = main rx = main
} else { } else {
for _, s := range f.slices { for _, idx := range f.sortedSliceIdxLocked() {
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt { if s := f.slices[idx]; sameSplit(s) {
rx = s rx = s
break break
} }
} }
} }
if rx == nil { if rx == nil {
return main, nil, nil // tx slice alone (simplex) → not split return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
} }
return main, rx, txS return main, rx, txS
} }
@@ -927,6 +1032,15 @@ func (f *Flex) SetFrequency(hz int64) error {
f.mu.Lock() f.mu.Lock()
idx := f.activeSliceIndexLocked() idx := f.activeSliceIndexLocked()
connected := f.conn != nil connected := f.conn != nil
// Optimistically update the active slice's cached freq NOW, before the radio
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
// jumped to the new band while the slice cache — which the FlexPanel and the
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
// the stale value. The real echo confirms/corrects this a moment later.
if s := f.slices[idx]; s != nil {
s.freqHz = hz
}
f.mu.Unlock() f.mu.Unlock()
if !connected { if !connected {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
@@ -952,6 +1066,13 @@ func (f *Flex) SetMode(mode string) error {
if fm == "" { if fm == "" {
return fmt.Errorf("flex: unsupported mode %q", mode) return fmt.Errorf("flex: unsupported mode %q", mode)
} }
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
// panel reflects it immediately instead of lagging the radio's echo.
f.mu.Lock()
if s := f.slices[idx]; s != nil {
s.mode = fm
}
f.mu.Unlock()
// "slice s <rx> mode=<m>" — set command per the SmartSDR API. // "slice s <rx> mode=<m>" — set command per the SmartSDR API.
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm)) f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
return nil return nil
@@ -975,8 +1096,30 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if color == "" { if color == "" {
color = "#FFFFA500" // opaque orange default color = "#FFFFA500" // opaque orange default
} }
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d", life := s.LifetimeSec
float64(s.FreqHz)/1e6, call, color, time.Now().Unix()) if life <= 0 {
life = 1800 // default 30 min (cluster spots)
}
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
// spotted gets its previous spot removed first — one live spot per call,
// refreshed, instead of a pile that all expire independently. NB: capture the
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
// whole Flex goroutine → the radio drops OFFLINE).
upperCall := strings.ToUpper(s.Callsign)
f.mu.Lock()
old, hadOld := f.spotByCall[upperCall]
if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
if hadOld {
f.send(fmt.Sprintf("spot remove %d", old))
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
if m := flexEncode(s.Mode); m != "" { if m := flexEncode(s.Mode); m != "" {
cmd += " mode=" + m cmd += " mode=" + m
} }
@@ -1022,6 +1165,7 @@ func (f *Flex) ClearSpots() error {
f.mu.Lock() f.mu.Lock()
f.spotIdx = map[int]bool{} f.spotIdx = map[int]bool{}
f.spotCall = map[int]string{} f.spotCall = map[int]string{}
f.spotByCall = map[string]int{}
connected := f.conn != nil connected := f.conn != nil
f.mu.Unlock() f.mu.Unlock()
if !connected { if !connected {
@@ -1127,6 +1271,10 @@ func (f *Flex) FlexState() FlexTXState {
Mon: f.tx.mon, Mon: f.tx.mon,
MonLevel: f.tx.monLevel, MonLevel: f.tx.monLevel,
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh,
MicProfile: f.micProfile,
MicProfiles: f.micProfiles,
ATUStatus: f.tx.atuStatus, ATUStatus: f.tx.atuStatus,
ATUMemories: f.tx.atuMemories, ATUMemories: f.tx.atuMemories,
// CW keyer (defaults applied so the sliders show sane values pre-read). // CW keyer (defaults applied so the sliders show sane values pre-read).
@@ -1177,6 +1325,12 @@ func (f *Flex) FlexState() FlexTXState {
st.ANFLevel = rx.anfLevel st.ANFLevel = rx.anfLevel
st.APF = rx.apf st.APF = rx.apf
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel
st.RIT = rx.rit
st.RITFreq = rx.ritFreq
st.XIT = rx.xit
st.XITFreq = rx.xitFreq
st.FilterLo = rx.filterLo st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt st.RXAnt = rx.rxAnt
@@ -1201,7 +1355,7 @@ func (f *Flex) FlexState() FlexTXState {
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
for _, id := range ids { for _, id := range ids {
mi := f.meterMeta[id] mi := f.meterMeta[id]
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi}) st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
} }
} }
return st return st
@@ -1245,6 +1399,14 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.rxAnt = fmt.Sprint(val) rx.rxAnt = fmt.Sprint(val)
case "txant": case "txant":
rx.txAnt = fmt.Sprint(val) rx.txAnt = fmt.Sprint(val)
case "rit_on":
rx.rit = val == "1"
case "rit_freq":
rx.ritFreq = toInt(val)
case "xit_on":
rx.xit = val == "1"
case "xit_freq":
rx.xitFreq = toInt(val)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -1352,8 +1514,30 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the
// radio's own knob.
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
func clampOffset(hz int) int {
if hz > 99999 {
return 99999
}
if hz < -99999 {
return -99999
}
return hz
}
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
// ── CW keyer controls (top-level "cw" commands) ── // ── CW keyer controls (top-level "cw" commands) ──
@@ -1571,6 +1755,45 @@ func (f *Flex) SetMic(l int) error {
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l }) return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
} }
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
// clamps to legal values per mode; we just pass them through in one command so
// both edges move together.
func (f *Flex) SetTXFilter(low, high int) error {
if low < 0 {
low = 0
}
if high < low {
high = low
}
// Guard both status field names (the echo comes back as lo/hi, not
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
f.mu.Lock()
f.txSetAt["hi"] = time.Now()
f.mu.Unlock()
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
}
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
// spaces). Optimistically caches the selection.
func (f *Flex) SetMicProfile(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("flex: empty mic profile")
}
f.mu.Lock()
connected := f.conn != nil
if connected {
f.micProfile = name
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
return nil
}
func (f *Flex) ATUStart() error { func (f *Flex) ATUStart() error {
if !f.connected() { if !f.connected() {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
@@ -1790,6 +2013,23 @@ func parseFloatDefault(s string, def float64) float64 {
} }
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode. // flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
// Flex) and digital voice never operate split, so they return "" (not
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
// independent operations, not a split.
func flexSplitClass(rawMode string) string {
switch flexModeToADIF(rawMode) {
case "SSB", "AM", "FM":
return "PHONE"
case "CW":
return "CW"
default: // DATA, RTTY, DIGITALVOICE, "" → never split
return ""
}
}
func flexModeToADIF(m string) string { func flexModeToADIF(m string) string {
switch strings.ToUpper(strings.TrimSpace(m)) { switch strings.ToUpper(strings.TrimSpace(m)) {
case "USB", "LSB": case "USB", "LSB":
+49
View File
@@ -0,0 +1,49 @@
package cat
import "testing"
// A genuine split is two same-band slices in the SAME split class (both PHONE or
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
func TestOperatingSplitMode(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
cases := []struct {
name string
slices map[int]*flexSlice
wantSplit bool
}{
{"phone split (both USB)", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_200_000, "USB", false, true),
}, true},
{"CW split (both CW)", map[int]*flexSlice{
0: mk(7_010_000, "CW", true, false),
1: mk(7_025_000, "CW", false, true),
}, true},
{"SSB + FT8 same band → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(14_074_000, "DIGU", false, true),
}, false},
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
0: mk(14_074_000, "DIGU", true, false),
1: mk(14_080_000, "DIGU", false, true),
}, false},
{"different bands → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false),
1: mk(7_100_000, "LSB", false, true),
}, false},
{"simplex (single slice) → not split", map[int]*flexSlice{
0: mk(14_100_000, "USB", true, true),
}, false},
}
for _, c := range cases {
f := &Flex{slices: c.slices}
_, rx, tx := f.operatingLocked()
gotSplit := rx != nil && tx != nil
if gotSplit != c.wantSplit {
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
}
}
}
+219
View File
@@ -0,0 +1,219 @@
package cat
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
// It is the third stream alongside control (50001) and CI-V (50002): once the
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
// PROVEN transport — and hands each received audio payload to a sink callback
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
//
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
// icnLocalID, icnLE) and the same seq/retransmit discipline.
//
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
// confirmed/corrected on the first on-rig test without a packet capture, the same
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"net"
"sync"
"sync/atomic"
"time"
)
// icaAudioOffset is where the PCM payload begins inside an audio data packet
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
// is a one-line edit.
const icaAudioOffset = 0x16
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
// offset verification. After the layout is confirmed on a real rig this can go to
// 0 (or the const above corrected).
const icaDumpFirst = 6
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
// 5) will add an encode+send path mirroring icomNet.Write.
type icomAudio struct {
conn *net.UDPConn
aID, aRemote uint32
sink func([]byte) // receives each raw audio payload (app decodes + plays)
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
rxHaveSeq bool
rxLastSeq uint16
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{}
closeOnce sync.Once
}
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() {
a.closeOnce.Do(func() {
close(a.done)
for i := 0; i < 3; i++ {
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
time.Sleep(15 * time.Millisecond)
}
_ = a.conn.Close()
debugLog.Printf("icom audio: stream closed")
})
}
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
// audioport=50003) must already have authorized it. sink receives each raw audio
// payload. cancel aborts a slow dial (Stop/Start).
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
if err != nil {
return nil, err
}
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
if err != nil {
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
return nil, err
}
aID := icnLocalID(conn)
aRemote, err := icnHandshake(conn, aID, cancel)
if err != nil {
_ = conn.Close()
debugLog.Printf("icom audio: handshake FAILED: %v", err)
return nil, err
}
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
}
a.markRx()
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
go a.audioPump()
return a, nil
}
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
// requests retransmits for lost packets, and hands each audio payload to sink.
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastReq := time.Now()
for {
select {
case <-a.done:
return
default:
}
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
a.markRx()
switch typ := icnLE.Uint16(buf[4:]); {
case typ == 0x07: // ping
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
case typ == 0x05: // rig-initiated disconnect
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
case typ == 0x00 && k > icaAudioOffset: // audio data packet
a.trackRxSeq(icnLE.Uint16(buf[6:]))
if a.dumped < icaDumpFirst {
a.dumped++
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
}
if a.sink != nil {
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
a.sink(payload)
}
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
}
}
}
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
// the audio stream owns its own seq state with no shared locking.
func (a *icomAudio) trackRxSeq(seq uint16) {
if !a.rxHaveSeq {
a.rxHaveSeq = true
a.rxLastSeq = seq
return
}
switch d := int16(seq - a.rxLastSeq); {
case d == 0:
case d < 0:
delete(a.rxMissing, seq)
case d == 1:
a.rxLastSeq = seq
case int(d) <= icnMaxMissing:
for f := a.rxLastSeq + 1; f != seq; f++ {
a.rxMissing[f] = 0
}
a.rxLastSeq = seq
default:
a.rxMissing = make(map[uint16]int)
a.rxLastSeq = seq
}
}
func (a *icomAudio) sendRetransmitReq() {
if len(a.rxMissing) == 0 {
return
}
if len(a.rxMissing) > icnMaxMissing {
a.rxMissing = make(map[uint16]int)
return
}
var seqs []uint16
for s, cnt := range a.rxMissing {
if cnt >= 4 {
delete(a.rxMissing, s)
continue
}
a.rxMissing[s] = cnt + 1
seqs = append(seqs, s)
}
switch {
case len(seqs) == 0:
return
case len(seqs) == 1:
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
default:
b := make([]byte, 16+4*len(seqs))
icnLE.PutUint32(b[0:], uint32(len(b)))
icnLE.PutUint16(b[4:], 0x01)
icnLE.PutUint32(b[8:], a.aID)
icnLE.PutUint32(b[12:], a.aRemote)
off := 16
for _, s := range seqs {
icnLE.PutUint16(b[off:], s)
icnLE.PutUint16(b[off+2:], s)
off += 4
}
_, _ = a.conn.Write(b)
}
}
+38 -8
View File
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network // NewIcomNet builds an (unconnected) Icom backend whose transport is the network
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1 // stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
// credentials. Reuses the whole IcomSerial controller — only `open` differs. // credentials. Reuses the whole IcomSerial controller — only `open` differs.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial { //
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
// non-nil the conninfo asks the rig to stream audio and each received payload is
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF { if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610 civAddr = 0x98 // IC-7610
} }
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
b.dialMu.Lock() b.dialMu.Lock()
cancel := b.dialCancel cancel := b.dialCancel
b.dialMu.Unlock() b.dialMu.Unlock()
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel) return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
} }
return b return b
} }
@@ -130,6 +136,10 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive(). // but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64 lastRx atomic.Int64
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
done chan struct{} done chan struct{}
closeOnce sync.Once closeOnce sync.Once
} }
@@ -232,6 +242,9 @@ var icnTrace = false
func (n *icomNet) Close() error { func (n *icomNet) Close() error {
n.closeOnce.Do(func() { n.closeOnce.Do(func() {
close(n.done) close(n.done)
if n.audio != nil {
n.audio.Close()
}
// Tell the rig we're leaving so it frees its SINGLE control session at // Tell the rig we're leaving so it frees its SINGLE control session at
// once. If it never gets a disconnect it holds the session for minutes and // once. If it never gets a disconnect it holds the session for minutes and
// refuses every new login — which is why a lost link (or a hard app exit) // refuses every new login — which is why a lost link (or a hard app exit)
@@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) {
// ------------------------- connect ------------------------- // ------------------------- connect -------------------------
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) { func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr) wantAudio := audioSink != nil
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
// ---- control stream (50001): handshake → login → token → conninfo ---- // ---- control stream (50001): handshake → login → token → conninfo ----
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001")) craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
if err != nil { if err != nil {
@@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
rigMAC = make([]byte, 6) rigMAC = make([]byte, 6)
} }
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003)) var rxEnable byte
if wantAudio {
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
cTracked++ cTracked++
cInner++ cInner++
drainEnd := time.Now().Add(500 * time.Millisecond) drainEnd := time.Now().Add(500 * time.Millisecond)
@@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
go n.ctrlPump() go n.ctrlPump()
go n.civPump() go n.civPump()
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
// CAT works without audio, so we log and continue rather than tear down a
// perfectly good control/CI-V session.
if wantAudio {
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
} else {
n.audio = a
}
}
return n, nil return n, nil
} }
@@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
return b return b
} }
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte { func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
b := make([]byte, 0x90) b := make([]byte, 0x90)
icnLE.PutUint32(b[0:], 0x90) icnLE.PutUint32(b[0:], 0x90)
icnLE.PutUint16(b[6:], seq) icnLE.PutUint16(b[6:], seq)
@@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x2a:0x30], rigMAC) copy(b[0x2a:0x30], rigMAC)
copy(b[0x40:0x60], []byte("IC-7610")) copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user)) copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = 0x00 // rxenable (audio off — CI-V only) b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000) icnBE.PutUint32(b[0x74:], 16000)
+19 -4
View File
@@ -210,17 +210,25 @@ func (b *IcomSerial) Connect() error {
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone) go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
} }
// Best-effort model identification: ask the rig for its own CI-V address. // Best-effort model identification: ask the rig for its own CI-V address. The
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
// reliable model signal even when the user runs the rig at a non-default CI-V
// address (a common trick to make CAT "just work" at 0x98).
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil { if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00 return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
}); err == nil { }); err == nil {
b.model = civ.ModelName(f.Data[1]) b.model = civ.ModelName(f.Data[1])
idAddr = f.Data[1]
} }
} }
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub // Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not. // selector byte; single-scope rigs (IC-7300…) do not. Decide from the
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2 // IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network // Defer the DSP snapshot until the rig actually answers CI-V. Over the network
// the rig may still be booting (or off) at Connect, so an immediate readDSP // the rig may still be booting (or off) at Connect, so an immediate readDSP
// would time out and leave every control at 0 / off with no retry. ReadState // would time out and leave every control at 0 / off with no retry. ReadState
@@ -590,7 +598,7 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
} }
continue continue
} }
if rawN < 4 { if rawN < 24 {
rawN++ rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data) applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
} }
@@ -696,6 +704,13 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader // and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop. // routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error { func (b *IcomSerial) SetScope(on bool) error {
if on {
// Context for the scope-diagnostic log: which rig + whether we expect the
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
// but nothing shows, compare its `icom scope raw` frames against this.
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
}
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and // Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command. // continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil { if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
+10
View File
@@ -414,6 +414,16 @@ func (o *OmniRig) SetPTT(on bool) error {
debugLog.Printf("OmniRig.SetPTT error: %v", err) debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err) return fmt.Errorf("set Tx=%s: %w", name, err)
} }
// Read the Tx param straight back. OmniRig is async — this may still show the
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
txState := "PM_RX"
if v.Val&pmTX != 0 {
txState = "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
}
return nil return nil
} }
+17
View File
@@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
return Exception{}, false return Exception{}, false
} }
// HasException reports whether the callsign has ANY full-call exception (at any
// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from
// 2024) — where cty.dat's date-blind exact-call override is wrong for older
// QSOs — from an ordinary call cty.dat handles fine.
func (db *DB) HasException(call string) bool {
if db == nil {
return false
}
c := strings.ToUpper(strings.TrimSpace(call))
for _, key := range candidates(c) {
if len(db.exceptions[key]) > 0 {
return true
}
}
return false
}
// candidates yields the call and a version with one trailing affix removed. // candidates yields the call and a version with one trailing affix removed.
func candidates(c string) []string { func candidates(c string) []string {
out := []string{c} out := []string{c}
+50
View File
@@ -0,0 +1,50 @@
package clublog
import (
"testing"
"time"
)
func d(s string) time.Time {
t, _ := time.Parse("2006-01-02", s)
return t
}
// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England
// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012
// QSO — the date-aware ClubLog cascade must give England then, Scotland now.
func TestG1TDatedException(t *testing.T) {
db := &DB{
exceptions: map[string][]Exception{
"G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}},
},
prefixes: map[string][]Exception{
"G": {{Entity: "England", ADIF: 223}},
},
}
// 2012 QSO: exception doesn't cover it, but the call HAS an exception →
// caller should use the prefix (England).
if _, ok := db.Resolve("G1T", d("2012-07-29")); ok {
t.Fatalf("2012: exception should NOT cover a pre-2024 date")
}
if !db.HasException("G1T") {
t.Fatalf("HasException(G1T) should be true")
}
pe, pok := db.ResolvePrefix("G1T", d("2012-07-29"))
if !pok || pe.Entity != "England" {
t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok)
}
// 2025 QSO: the exception covers it → Scotland.
e, ok := db.Resolve("G1T", d("2025-01-01"))
if !ok || e.Entity != "Scotland" {
t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok)
}
// An ordinary call with no exception → HasException false (caller leaves it
// to cty.dat).
if db.HasException("G4ABC") {
t.Fatalf("HasException(G4ABC) should be false")
}
}
+27
View File
@@ -50,6 +50,17 @@ func (m *Manager) Info() (date string, count int) {
return m.db.Date(), m.db.Count() return m.db.Date(), m.db.Count()
} }
// NeedsRefresh reports whether the cached country file is missing or older than
// maxAge — so the caller re-downloads it to pick up newly-added date-ranged
// exceptions (e.g. an event call's fresh SARDINIA period).
func (m *Manager) NeedsRefresh(maxAge time.Duration) bool {
fi, err := os.Stat(m.Path())
if err != nil {
return true // missing
}
return time.Since(fi.ModTime()) > maxAge
}
// EnsureLoaded loads the cached file into memory if present. Does NOT download. // EnsureLoaded loads the cached file into memory if present. Does NOT download.
func (m *Manager) EnsureLoaded() error { func (m *Manager) EnsureLoaded() error {
if m.Loaded() { if m.Loaded() {
@@ -125,3 +136,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
} }
return db.Resolve(call, date) return db.Resolve(call, date)
} }
// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table.
func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
return db.ResolvePrefix(call, date)
}
// HasException reports whether the call has any full-call exception (any date).
func (m *Manager) HasException(call string) bool {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
return db.HasException(call)
}
+139 -9
View File
@@ -61,6 +61,11 @@ type Spot struct {
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"` Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours
// ago is not on the air now.
Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name POTAName string `json:"pota_name,omitempty"` // park name
} }
@@ -92,6 +97,21 @@ type ServerStatus struct {
} }
// session is one telnet connection bound to a single server config. // session is one telnet connection bound to a single server config.
// Line is one raw line of cluster traffic: everything the server says, plus the
// commands we send (echoed, so the console reads as a conversation).
//
// Spots are PARSED OUT of this stream — but everything else used to be silently
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
// the command box look broken. The raw stream is the fix: the console shows what
// the server actually said, spot or not.
type Line struct {
ServerID int64 `json:"server_id"`
ServerName string `json:"server_name"`
Text string `json:"text"`
Sent bool `json:"sent"` // true = a command WE sent
At string `json:"at"` // HH:MM:SS, local
}
// Internal — callers use Manager. The onStatus callback is fire-and- // Internal — callers use Manager. The onStatus callback is fire-and-
// forget: it tells the manager something changed; the frontend fetches // forget: it tells the manager something changed; the frontend fetches
// the new aggregate via Status() rather than receiving per-server diffs. // the new aggregate via Status() rather than receiving per-server diffs.
@@ -99,6 +119,7 @@ type session struct {
cfg ServerConfig cfg ServerConfig
login string login string
onSpot func(Spot) onSpot func(Spot)
onLine func(Line)
onStatus func() onStatus func()
mu sync.RWMutex mu sync.RWMutex
@@ -117,6 +138,7 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
sessions map[int64]*session sessions map[int64]*session
onSpot func(Spot) onSpot func(Spot)
onLine func(Line)
onStatus func() onStatus func()
} }
@@ -124,10 +146,11 @@ type Manager struct {
// spot (with the source server filled in). emitStatusChanged is called // spot (with the source server filled in). emitStatusChanged is called
// whenever ANY server's status changes — the frontend then re-fetches // whenever ANY server's status changes — the frontend then re-fetches
// the aggregate Status() via a Wails binding. // the aggregate Status() via a Wails binding.
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager { func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
return &Manager{ return &Manager{
sessions: make(map[int64]*session), sessions: make(map[int64]*session),
onSpot: emitSpot, onSpot: emitSpot,
onLine: emitLine,
onStatus: emitStatusChanged, onStatus: emitStatusChanged,
} }
} }
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
cfg: cfg, cfg: cfg,
login: login, login: login,
onSpot: m.onSpot, onSpot: m.onSpot,
onLine: m.onLine,
onStatus: m.emitStatus, onStatus: m.emitStatus,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
doneCh: make(chan struct{}), doneCh: make(chan struct{}),
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
} }
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n")) _, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
if err == nil {
// Echo it into the console, so the operator sees WHAT was sent and can pair
// it with the reply that follows. A console that only shows one side of the
// conversation is barely better than none.
s.emitLine(cmd, true)
}
return err return err
} }
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
}() }()
} }
// Init commands: fire 1s after login goes through. Each command on // Init commands, once per connection (so they replay after a reconnect).
// its own line; blank lines and "//" comments are skipped. //
// Timing: the cluster needs a moment after login before it will take commands,
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
// happily swallows a burst of back-to-back lines and silently ignores half of
// them. A fast-but-dropped command is worse than a slow one.
//
// They go through s.send() — NOT a raw conn.Write, which is what they used to
// do. That matters for two reasons: the raw write skipped the write deadline
// (a wedged server could hang this goroutine forever), and it skipped the
// console echo, so there was no way to SEE whether your init commands had
// actually been applied. Now you watch them go out and the reply come back.
initFired := false initFired := false
fireInitCommands := func() { fireInitCommands := func() {
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" { if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
} }
initFired = true initFired = true
go func() { go func() {
time.Sleep(1 * time.Second) time.Sleep(initCommandLeadIn)
for _, line := range strings.Split(s.cfg.InitCommands, "\n") { for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
line = strings.TrimRight(line, "\r") line = strings.TrimSpace(strings.TrimRight(line, "\r"))
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") { if line == "" || strings.HasPrefix(line, "//") {
continue continue
} }
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
return return
default: default:
} }
_, _ = conn.Write([]byte(line + "\r\n")) if err := s.send(line); err != nil {
time.Sleep(500 * time.Millisecond) s.emitLine("init command failed: "+line+" — "+err.Error(), false)
return
}
time.Sleep(initCommandGap)
} }
}() }()
} }
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
fireInitCommands() fireInitCommands()
} }
if spot, ok := parseSpot(line); ok { // EVERY line goes to the console — spot or not. This is the whole point:
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
// (which is what happened before) made the command box look inert.
s.emitLine(line, false)
// Live broadcast first; then the SH/DX table form. Without the second, a
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
// "SH/DX/100" looked like it did nothing at all.
spot, ok := parseSpot(line)
if !ok {
spot, ok = parseShowDX(line)
}
if ok {
spot.SourceID = s.cfg.ID spot.SourceID = s.cfg.ID
spot.SourceName = s.cfg.Name spot.SourceName = s.cfg.Name
s.mu.Lock() s.mu.Lock()
// Historical spots are a replay of the past, not new traffic — counting
// them would inflate the server's "spots received" figure.
if !spot.Historical {
s.spotsCnt++ s.spotsCnt++
s.status.SpotsCount = s.spotsCnt s.status.SpotsCount = s.spotsCnt
}
s.mu.Unlock() s.mu.Unlock()
if s.onSpot != nil { if s.onSpot != nil {
s.onSpot(spot) s.onSpot(spot)
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
} }
} }
// emitLine hands one line of traffic to the console. Blank lines are dropped —
// clusters send plenty and they'd only pad the scrollback.
func (s *session) emitLine(text string, sent bool) {
if s.onLine == nil || strings.TrimSpace(text) == "" {
return
}
s.onLine(Line{
ServerID: s.cfg.ID,
ServerName: s.cfg.Name,
Text: strings.TrimRight(text, "\r\n"),
Sent: sent,
At: time.Now().Format("15:04:05"),
})
}
// ---------- parsing ---------- // ---------- parsing ----------
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]". // spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
@@ -465,6 +538,63 @@ var spotRE = regexp.MustCompile(
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`, `^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
) )
// Pacing for the per-server init commands.
//
// A cluster is not ready to take commands the instant the login line goes out —
// it is still printing its banner — so we wait before the first one. And they are
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
// back-to-back lines and silently apply only some of them: the connection stays
// up, no error is returned, your filters just aren't set. A command that is
// dropped is far worse than one that is slow.
const (
initCommandLeadIn = 1500 * time.Millisecond
initCommandGap = 700 * time.Millisecond
)
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
// broadcast format, and therefore matched nothing at all:
//
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter>
//
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
// regex, and get dropped. The decimal point in the frequency is required — it is
// what keeps ordinary prose out of the spot grid.
var showDXRE = regexp.MustCompile(
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
)
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
// Historical: these are PAST spots, so they belong in the grid but must not fire
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
// station spotted three hours ago is not on the air now.
func parseShowDX(line string) (Spot, bool) {
if strings.Contains(strings.ToUpper(line), "DX DE") {
return Spot{}, false // that's the broadcast form; spotRE owns it
}
m := showDXRE.FindStringSubmatch(line)
if m == nil {
return Spot{}, false
}
khz, err := strconv.ParseFloat(m[1], 64)
if err != nil || khz <= 0 {
return Spot{}, false
}
hz := int64(khz * 1000)
return Spot{
Spotter: strings.ToUpper(m[6]),
DXCall: strings.ToUpper(m[2]),
FreqKHz: khz,
FreqHz: hz,
Band: bandFromHz(hz),
Comment: strings.TrimSpace(m[5]),
TimeUTC: m[4] + "Z",
ReceivedAt: time.Now(),
Raw: line,
Historical: true,
}, true
}
func parseSpot(line string) (Spot, bool) { func parseSpot(line string) (Spot, bool) {
m := spotRE.FindStringSubmatch(line) m := spotRE.FindStringSubmatch(line)
if m == nil { if m == nil {
+54
View File
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
} }
} }
} }
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
// arrived, matched nothing and was dropped: the command looked like it did
// nothing at all. These lines must now land in the grid, flagged Historical.
func TestParseShowDX(t *testing.T) {
cases := []struct {
line string
call string
khz float64
spotter string
}{
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
// No spotter brackets — still a spot, just without a DE.
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
}
for _, c := range cases {
got, ok := parseShowDX(c.line)
if !ok {
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
continue
}
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
}
if !got.Historical {
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
}
if got.Band == "" {
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
}
}
}
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
// chatter turned into fake spots would be worse than no parser at all.
func TestParseShowDXRejectsNoise(t *testing.T) {
noise := []string{
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
"Hello and welcome to the DXSpider cluster",
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
"",
"There are 42 users online",
}
for _, l := range noise {
if s, ok := parseShowDX(l); ok {
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
package db
import (
"database/sql"
"database/sql/driver"
"errors"
"net"
"strings"
"github.com/go-sql-driver/mysql"
)
// IsConnLost reports whether err means the database was UNREACHABLE (network
// down, server gone, dead pooled connection) as opposed to a legitimate
// server-side SQL error (constraint violation, bad data, syntax).
//
// This distinction is the linchpin of the offline safety net: a QSO is parked in
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
// genuine data bug would silently vanish into the file instead of being
// reported — the worst possible outcome.
//
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
// a connection loss, whatever its code.
func IsConnLost(err error) bool {
if err == nil {
return false
}
// The server responded → a real SQL error, not a lost link.
var me *mysql.MySQLError
if errors.As(err, &me) {
return false
}
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
return true
}
var ne net.Error
if errors.As(err, &ne) {
return true
}
var oe *net.OpError
if errors.As(err, &oe) {
return true
}
// The MySQL driver reports a few of these as plain strings.
s := strings.ToLower(err.Error())
for _, p := range []string{
"invalid connection",
"bad connection",
"connection refused",
"connection reset",
"broken pipe",
"no such host",
"i/o timeout",
"dial tcp",
"unexpected eof",
"driver: bad connection",
"can't connect",
"network is unreachable",
"host is unreachable",
} {
if strings.Contains(s, p) {
return true
}
}
return false
}
+13 -5
View File
@@ -339,17 +339,25 @@ func normalizeCallsign(s string) string {
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not // which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
// Ecuador). This is the same class of rule as KG4 and /MM. // Ecuador). This is the same class of rule as KG4 and /MM.
if areaDigit != 0 { if areaDigit != 0 {
main = replaceFirstDigit(main, areaDigit) main = replaceAreaDigit(main, areaDigit)
} }
return main return main
} }
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to // replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
// apply a "/N" call-area change). Returns the call unchanged if it has no digit. // apply a "/N" call-area change). The area digit is the first digit that comes
func replaceFirstDigit(call string, d byte) string { // AFTER a prefix letter — NOT a leading digit that is part of a digit-first
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
// unchanged if it has no such digit.
func replaceAreaDigit(call string, d byte) string {
b := []byte(call) b := []byte(call)
seenLetter := false
for i := range b { for i := range b {
if b[i] >= '0' && b[i] <= '9' { switch {
case b[i] >= 'A' && b[i] <= 'Z':
seenLetter = true
case b[i] >= '0' && b[i] <= '9' && seenLetter:
b[i] = d b[i] = d
return string(b) return string(b)
} }
+3
View File
@@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) {
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity "F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5 "F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8) "HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
"DL/F4BPO": "DL", "DL/F4BPO": "DL",
"MM/KA9P": "MM", // leading MM = Scotland operating prefix "MM/KA9P": "MM", // leading MM = Scotland operating prefix
"MM/LY3X/P": "MM", "MM/LY3X/P": "MM",
+64 -3
View File
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
// N QSOs is one HTTP request instead of N realtime.php calls. // N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php" const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogUserAgent identifies OpsLog to Club Log. Go's default
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
// returns 403 Forbidden before the request reaches the app), so every request
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log // clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the // requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in // user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err) return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
} }
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil { if client == nil {
client = &http.Client{Timeout: 120 * time.Second} client = &http.Client{Timeout: 120 * time.Second}
} }
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil return UploadResult{OK: true, Message: msg}, nil
} }
if msg == "" { diag := clublogFailureDiag(resp, msg)
msg = fmt.Sprintf("HTTP %d", resp.StatusCode) return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
} }
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
// clublogFailureDiag turns a non-200 response into a readable one-liner that
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
// page means the request never reached the app; the fingerprint headers below
// tell the operator whether it's Club Log's own WAF or something on their side.
func clublogFailureDiag(resp *http.Response, body string) string {
server := strings.TrimSpace(resp.Header.Get("Server"))
// Notable intermediary fingerprints — presence points at the culprit.
fp := []string{}
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
fp = append(fp, h+"="+v)
}
}
// Collapse the HTML error page to something short.
summary := stripHTMLBrief(body)
if summary == "" {
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
if server != "" {
out += " [server=" + server + "]"
}
if len(fp) > 0 {
out += " [" + strings.Join(fp, " ") + "]"
}
return out
}
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
// chars of visible text — enough to read "403 Forbidden" without the markup.
func stripHTMLBrief(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := strings.Join(strings.Fields(b.String()), " ")
if len(out) > 160 {
out = out[:160] + "…"
}
return out
} }
// TestClublog validates the configured credentials by attempting a no-op // TestClublog validates the configured credentials by attempting a no-op
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err) return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil { if client == nil {
client = &http.Client{Timeout: 20 * time.Second} client = &http.Client{Timeout: 20 * time.Second}
} }
+149
View File
@@ -53,6 +53,155 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
return msg, nil return msg, nil
} }
// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
// the account's *received* eQSLs (confirmations) and returns an HTML page that
// links to the generated .adi file; we then fetch that file (two-step API).
const (
eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
eqslBaseURL = "https://www.eQSL.cc"
)
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
// path may be root-relative or a bare filename. Match case-insensitively with
// optional quoting; resolve to an absolute URL afterwards.
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
// then, incremental "last download"); qthNick comes from the config when the
// account has several QTH profiles.
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
if user == "" || strings.TrimSpace(cfg.Password) == "" {
return "", fmt.Errorf("eqsl: username/password not set")
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
q := url.Values{}
q.Set("UserName", user)
q.Set("Password", cfg.Password)
if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
q.Set("QTHNickname", nick)
}
if s := eqslRcvdSince(since); s != "" {
q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
}
// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
if err != nil {
return "", err
}
if reason := authErrEQSL(page); reason != "" {
return "", fmt.Errorf("eqsl: %s", reason)
}
m := eqslAdiHrefRe.FindStringSubmatch(page)
if m == nil {
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
}
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
link := strings.TrimSpace(m[1])
link = strings.ReplaceAll(link, `\`, "/")
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
var fileURL string
switch {
case strings.HasPrefix(strings.ToLower(link), "http"):
fileURL = link
case strings.HasPrefix(link, "/"):
fileURL = eqslBaseURL + link
default:
fileURL = eqslBaseURL + "/" + link
}
// Step 2: fetch the generated .adi file.
adif, err := eqslGet(ctx, client, fileURL)
if err != nil {
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
}
return adif, nil
}
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
// own error/notice text if present, else a short tag-stripped snippet of the page
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
func eqslNoLinkReason(page string) string {
low := strings.ToLower(page)
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
if i := strings.Index(low, marker); i >= 0 {
seg := page[i:]
if len(seg) > 160 {
seg = seg[:160]
}
return strings.Join(strings.Fields(stripTags(seg)), " ")
}
}
txt := strings.Join(strings.Fields(stripTags(page)), " ")
if len(txt) > 200 {
txt = txt[:200]
}
if txt == "" {
txt = "empty response"
}
return txt
}
// stripTags removes HTML tags for a readable one-line diagnostic.
func stripTags(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
return b.String()
}
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
// a non-200 status.
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("eqsl: build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("eqsl: request failed: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
}
return string(b), nil
}
// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
func eqslRcvdSince(since string) string {
s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
return s + "0000"
}
return s
}
// authErrEQSL returns a reason when the response signals bad credentials, else // authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd". // "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string { func authErrEQSL(body string) string {
+11
View File
@@ -3,6 +3,7 @@ package extsvc
import ( import (
"context" "context"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -11,6 +12,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
if runErr != nil { if runErr != nil {
if ee, ok := runErr.(*exec.ExitError); ok { if ee, ok := runErr.(*exec.ExitError); ok {
code = ee.ExitCode() code = ee.ExitCode()
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
// rights (its "Run as administrator" compatibility flag, or an
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
// refuses to launch it. Actionable message instead of the raw error.
return UploadResult{}, fmt.Errorf(
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
"Or run OpsLog itself as administrator.", tqsl)
} else { } else {
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr) return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
} }
+31 -3
View File
@@ -43,9 +43,15 @@ type Event struct {
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
DXGrid string // ServiceWSJT (Status) DXGrid string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status) Mode string // ServiceWSJT (Status/Decode)
FreqHz int64 // ServiceWSJT (Status) FreqHz int64 // ServiceWSJT (Status)
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
// A WSJT-X Decode (heard station) to render on the panadapter.
DecodeCall string // transmitting (DE) callsign
DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ
} }
// Server is a single inbound UDP listener. // Server is a single inbound UDP listener.
@@ -57,6 +63,8 @@ type Server struct {
done chan struct{} done chan struct{}
stopped bool stopped bool
mu sync.Mutex mu sync.Mutex
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
} }
func newServer(cfg Config, out chan<- Event) *Server { func newServer(cfg Config, out chan<- Event) *Server {
@@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
return return
} }
if !ok { if !ok {
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
return return
} }
// Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode {
s.mu.Lock()
s.lastDialHz = w.FreqHz
s.mu.Unlock()
}
if w.IsDecode {
s.mu.Lock()
dial := s.lastDialHz
s.mu.Unlock()
if dial <= 0 {
return // no dial freq yet (Status not seen) → can't place the spot
}
ev.DecodeCall = w.DecodeCall
ev.DecodeFreqHz = dial + w.DeltaFreqHz
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
ev.Mode = w.Mode
break
}
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n", applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF)) s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
ev.DXCall = w.DXCall ev.DXCall = w.DXCall
@@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
return return
} }
// Empty events are useless; skip. // Empty events are useless; skip.
if ev.DXCall == "" && ev.LoggedADIF == "" { if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
return return
} }
select { select {
+122 -5
View File
@@ -37,18 +37,27 @@ const (
) )
// WSJTEvent is the parsed, typed result of decoding a single packet. // WSJTEvent is the parsed, typed result of decoding a single packet.
// One of (DXCall, LoggedADIF) is non-empty depending on the message. // One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
type WSJTEvent struct { type WSJTEvent struct {
DXCall string // current "DX Call" field in the WSJT app DXCall string // current "DX Call" field in the WSJT app (Status)
DXGrid string // optional grid for that call DXGrid string // optional grid for that call (Status)
Mode string // FT8 / FT4 / … Mode string // FT8 / FT4 / …
FreqHz int64 // current dial freq when available FreqHz int64 // current dial freq when available (Status)
LoggedADIF string // full ADIF text when message is LoggedADIF LoggedADIF string // full ADIF text when message is LoggedADIF
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
// set here (Decode carries only the audio offset); the caller adds the last
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
IsDecode bool
DecodeCall string // the sender (DE) callsign extracted from the message text
DeltaFreqHz int64 // audio offset within the passband (Hz)
SNR int // reported signal-to-noise (dB)
IsCQ bool // the decode was a CQ call
} }
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we // ParseWSJT decodes one UDP packet. Returns ok=false for messages we
// don't care about (heartbeat, decode lines, clears, etc.). // don't care about (heartbeat, clears, etc.).
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if len(pkt) < 12 { if len(pkt) < 12 {
return WSJTEvent{}, false, fmt.Errorf("packet too short") return WSJTEvent{}, false, fmt.Errorf("packet too short")
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid)) ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
return ev, true, nil return ev, true, nil
case wsjtMsgDecode:
// Decode payload (v2):
// bool is_new
// quint32 time (ms since midnight)
// qint32 snr
// double delta_time (seconds)
// quint32 delta_frequency (Hz, audio offset in the passband)
// QUtf8 mode
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
// bool low_confidence
// bool off_air
var b uint8
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
return WSJTEvent{}, false, err
}
var t32, df uint32
var snr int32
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
return WSJTEvent{}, false, err
}
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
return WSJTEvent{}, false, err
}
var dt float64
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
return WSJTEvent{}, false, err
}
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
return WSJTEvent{}, false, err
}
mode, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
msg, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
call, isCQ := wsjtSender(msg)
if call == "" {
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
}
ev.IsDecode = true
ev.DecodeCall = call
ev.IsCQ = isCQ
ev.DeltaFreqHz = int64(df)
ev.SNR = int(snr)
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
return ev, true, nil
case wsjtMsgLoggedADIF: case wsjtMsgLoggedADIF:
// Payload: a single QString containing the ADIF record. // Payload: a single QString containing the ADIF record.
adif, err := readQString(r) adif, err := readQString(r)
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
return WSJTEvent{}, false, nil return WSJTEvent{}, false, nil
} }
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
// whether it was a CQ. Grammar:
//
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
// <to_call> <de_call> [report|…] → de_call, isCQ=false
//
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
func wsjtSender(message string) (call string, isCQ bool) {
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
if len(f) == 0 {
return "", false
}
if f[0] == "CQ" {
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
// 020) — it never looks like a callsign (no letter+digit mix).
idx := 1
if len(f) > 2 && !looksLikeCall(f[1]) {
idx = 2
}
if idx < len(f) && looksLikeCall(f[idx]) {
return f[idx], true
}
return "", true
}
// Standard exchange: the DE (sender) call is the second token.
if len(f) >= 2 && looksLikeCall(f[1]) {
return f[1], false
}
return "", false
}
// looksLikeCall is a loose callsign test: 312 chars of AZ/09//, with at least
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
// would otherwise pass.
func looksLikeCall(s string) bool {
switch s {
case "RR73", "RRR", "73":
return false
}
if len(s) < 3 || len(s) > 12 {
return false
}
hasL, hasD := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'A' && c <= 'Z':
hasL = true
case c >= '0' && c <= '9':
hasD = true
case c == '/':
default:
return false
}
}
return hasL && hasD
}
// readQString reads a Qt QString as written by QDataStream: an int32 byte // readQString reads a Qt QString as written by QDataStream: an int32 byte
// length (or -1 for null) followed by the UTF-8 bytes. // length (or -1 for null) followed by the UTF-8 bytes.
func readQString(r *bytes.Reader) (string, error) { func readQString(r *bytes.Reader) (string, error) {
@@ -0,0 +1,32 @@
package udp
import "testing"
func TestWSJTSender(t *testing.T) {
cases := []struct {
msg string
wantCall string
wantCQ bool
}{
{"CQ K1ABC FN42", "K1ABC", true},
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
{"W2XYZ K1ABC R-10", "K1ABC", false},
{"W2XYZ K1ABC RR73", "K1ABC", false},
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
// Non-callsign / free text → no sender.
{"TNX 73 GL", "", false},
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
{"", "", false},
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
}
for _, c := range cases {
call, cq := wsjtSender(c.msg)
if call != c.wantCall || cq != c.wantCQ {
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package lotwusers tracks which callsigns are LoTW users and when they last
// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a
// colour-coded badge next to the entered callsign (recent upload = likely to
// confirm on LoTW). The list is cached on disk so it survives restarts.
package lotwusers
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// feedURL is ARRL's public LoTW last-upload activity list (CSV:
// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign).
const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv"
const cacheFile = "lotw-user-activity.csv"
// Info is a lookup result for one callsign.
type Info struct {
IsUser bool `json:"is_user"`
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
}
// Manager holds the parsed list + cache location.
type Manager struct {
mu sync.RWMutex
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
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{
users: map[string]time.Time{},
dir: dataDir,
client: &http.Client{Timeout: 90 * 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 list, caches it to disk and replaces the in-memory
// map. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, 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("lotwusers: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
if err != nil {
return 0, fmt.Errorf("lotwusers: read: %w", err)
}
n := m.parse(body)
if n == 0 {
return 0, fmt.Errorf("lotwusers: feed 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 CSV bytes into the map and returns the count.
func (m *Manager) parse(data []byte) int {
users := make(map[string]time.Time, 1<<20)
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
call := strings.ToUpper(strings.TrimSpace(parts[0]))
if call == "" || strings.EqualFold(call, "callsign") {
continue // header row
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
if err != nil {
continue
}
users[call] = t
}
if len(users) == 0 {
return 0
}
m.mu.Lock()
m.users = users
m.mu.Unlock()
return len(users)
}
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
// the longest slash-separated segment — the base call LoTW is keyed on.
func (m *Manager) Lookup(callsign string) Info {
c := strings.ToUpper(strings.TrimSpace(callsign))
if c == "" {
return Info{DaysAgo: -1}
}
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.users) == 0 {
return Info{DaysAgo: -1}
}
t, ok := m.users[c]
if !ok && strings.Contains(c, "/") {
base := ""
for _, seg := range strings.Split(c, "/") {
if len(seg) > len(base) {
base = seg
}
}
t, ok = m.users[base]
}
if !ok {
return Info{DaysAgo: -1}
}
days := int(time.Since(t).Hours() / 24)
if days < 0 {
days = 0
}
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.users)
}
// 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
}
+10
View File
@@ -29,6 +29,16 @@ type Station struct {
DXCC int `json:"dxcc,omitempty"` DXCC int `json:"dxcc,omitempty"`
ITU int `json:"itu,omitempty"` ITU int `json:"itu,omitempty"`
CQ int `json:"cq,omitempty"` CQ int `json:"cq,omitempty"`
// Full lookup detail so a roster contact carries EVERYTHING into the QSO when
// put on air (was previously only name/qth — grid/address/etc. were lost).
Grid string `json:"grid,omitempty"`
Address string `json:"address,omitempty"`
State string `json:"state,omitempty"`
Cnty string `json:"cnty,omitempty"`
Cont string `json:"cont,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
Email string `json:"email,omitempty"`
Groups string `json:"groups,omitempty"` Groups string `json:"groups,omitempty"`
SIG string `json:"sig,omitempty"` SIG string `json:"sig,omitempty"`
SIGInfo string `json:"sig_info,omitempty"` SIGInfo string `json:"sig_info,omitempty"`
+202
View File
@@ -0,0 +1,202 @@
// Package offlineq is OpsLog's offline safety net.
//
// When the shared MySQL logbook is unreachable, a QSO must never be lost just
// because the network blinked. Instead of failing, the QSO is appended to a
// local ADIF file (the "outbox") and replayed into the database as soon as it
// comes back — then the file is ARCHIVED, never deleted.
//
// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs.
// There is no mirror, no pull, no merge, no tombstones — which is exactly why it
// stays small. The cost, accepted by design: during an outage you don't see other
// operators' QSOs and the worked-before check doesn't know about your pending
// ones. See the queue view in the UI for what's waiting.
//
// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder
// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're
// escaping in the first place.
package offlineq
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"hamlog/internal/adif"
"hamlog/internal/qso"
)
// FileName is the outbox. Pending QSOs accumulate here while the DB is down.
const FileName = "opslog-pending.adi"
// Queue owns the outbox file. All operations are serialised: the logging path
// (append) and the replay loop (read/rewrite/archive) run on different
// goroutines.
type Queue struct {
dir string
mu sync.Mutex
}
// New returns a queue backed by <dir>/opslog-pending.adi.
func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} }
// Path is the outbox file's location (shown in the UI so the operator always
// knows where their QSOs physically are).
func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) }
// newQueueID mints the id that makes replay idempotent.
func newQueueID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a
// repeated replay can recognise it. The file is created with an ADIF header on
// first use, then appended to — an append can't corrupt what's already there.
func (q *Queue) Append(rec qso.QSO) (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
if rec.Extras == nil {
rec.Extras = map[string]string{}
}
qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey])
if qid == "" {
qid = newQueueID()
rec.Extras[qso.OfflineQueueKey] = qid
}
if err := os.MkdirAll(q.dir, 0o755); err != nil {
return "", fmt.Errorf("offlineq: create dir: %w", err)
}
path := q.Path()
_, statErr := os.Stat(path)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return "", fmt.Errorf("offlineq: open %s: %w", path, err)
}
defer f.Close()
var b strings.Builder
if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
}
b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n"))
b.WriteString("\n")
if _, err := f.WriteString(b.String()); err != nil {
return "", fmt.Errorf("offlineq: write: %w", err)
}
// Flush to disk: the whole point is surviving a crash/power cut.
if err := f.Sync(); err != nil {
return "", fmt.Errorf("offlineq: sync: %w", err)
}
return qid, nil
}
// Pending parses the outbox into QSOs (each carrying its queue id in Extras).
// A missing file simply means nothing is pending.
func (q *Queue) Pending() ([]qso.QSO, error) {
q.mu.Lock()
defer q.mu.Unlock()
return q.pendingLocked()
}
func (q *Queue) pendingLocked() ([]qso.QSO, error) {
f, err := os.Open(q.Path())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer f.Close()
var out []qso.QSO
err = adif.Parse(f, func(rec adif.Record) error {
if v, ok := adif.RecordToQSO(rec); ok {
out = append(out, v)
}
return nil
})
if err != nil {
return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err)
}
return out, nil
}
// Count is how many QSOs are waiting (0 when the file is absent).
func (q *Queue) Count() int {
p, err := q.Pending()
if err != nil {
return 0
}
return len(p)
}
// Rewrite replaces the outbox with exactly these QSOs — used after a replay to
// keep only the ones that FAILED. Written to a temp file and renamed, so a crash
// mid-write can't truncate the queue.
func (q *Queue) Rewrite(keep []qso.QSO) error {
q.mu.Lock()
defer q.mu.Unlock()
return q.rewriteLocked(keep)
}
func (q *Queue) rewriteLocked(keep []qso.QSO) error {
path := q.Path()
if len(keep) == 0 {
// Nothing left: remove the (now empty) outbox. The caller archives the
// original content first, so this never destroys the only copy.
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("offlineq: remove: %w", err)
}
return nil
}
var b strings.Builder
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
for _, v := range keep {
b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n"))
b.WriteString("\n")
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("offlineq: write temp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("offlineq: replace: %w", err)
}
return nil
}
// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the
// QSOs always exist somewhere on disk even if the replay later turns out to have
// gone wrong. Deleting the only copy of someone's contacts is the one mistake
// you don't get to undo.
func (q *Queue) Archive() (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
data, err := os.ReadFile(q.Path())
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504"))
dst := filepath.Join(q.dir, name)
if err := os.WriteFile(dst, data, 0o644); err != nil {
return "", fmt.Errorf("offlineq: archive: %w", err)
}
return dst, nil
}
+93
View File
@@ -0,0 +1,93 @@
package offlineq
import (
"os"
"path/filepath"
"testing"
"time"
"hamlog/internal/qso"
)
// The outbox is the ONLY copy of a QSO logged while the database was down, so the
// round-trip must be lossless: append → read back identical (callsign, band, mode,
// date) and each record must carry a queue id (what makes the replay idempotent).
func TestQueueRoundTrip(t *testing.T) {
dir := t.TempDir()
q := New(dir)
if n := q.Count(); n != 0 {
t.Fatalf("fresh queue: count = %d, want 0", n)
}
mk := func(call, band, mode string) qso.QSO {
return qso.QSO{
Callsign: call, Band: band, Mode: mode,
QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC),
}
}
id1, err := q.Append(mk("K1ABC", "20m", "SSB"))
if err != nil {
t.Fatalf("append 1: %v", err)
}
id2, err := q.Append(mk("DL1XYZ", "40m", "CW"))
if err != nil {
t.Fatalf("append 2: %v", err)
}
if id1 == "" || id2 == "" || id1 == id2 {
t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2)
}
pending, err := q.Pending()
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 2 {
t.Fatalf("pending = %d, want 2", len(pending))
}
if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" {
t.Errorf("record 1 round-tripped wrong: %+v", pending[0])
}
if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" {
t.Errorf("record 2 round-tripped wrong: %+v", pending[1])
}
// The queue id must survive the ADIF round-trip — without it the replay
// can't be idempotent and a crash would duplicate contacts.
for i, p := range pending {
if p.Extras[qso.OfflineQueueKey] == "" {
t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey)
}
}
// Archive keeps a copy BEFORE we clear anything.
arch, err := q.Archive()
if err != nil || arch == "" {
t.Fatalf("archive: %v (path %q)", err, arch)
}
if _, err := os.Stat(arch); err != nil {
t.Fatalf("archive file missing: %v", err)
}
// Partial replay: the first QSO went in, the second failed → keep only it.
if err := q.Rewrite(pending[1:]); err != nil {
t.Fatalf("rewrite: %v", err)
}
left, err := q.Pending()
if err != nil {
t.Fatalf("pending after rewrite: %v", err)
}
if len(left) != 1 || left[0].Callsign != "DL1XYZ" {
t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left)
}
// Everything replayed → the outbox is removed (the archive still holds it).
if err := q.Rewrite(nil); err != nil {
t.Fatalf("rewrite empty: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) {
t.Errorf("outbox should be gone once fully replayed, stat err = %v", err)
}
if n := q.Count(); n != 0 {
t.Errorf("count after full replay = %d, want 0", n)
}
}
+28 -1
View File
@@ -46,6 +46,28 @@ func (r *Repo) List(ctx context.Context) ([]Record, error) {
return out, rows.Err() return out, rows.Err()
} }
// ListFor returns the templates VISIBLE to a profile: its own plus any shared
// (NULL-profile) ones — so each profile sees and defaults independently instead
// of one global list. Defaults first, then newest first.
func (r *Repo) ListFor(ctx context.Context, profileID int64) ([]Record, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+repoCols+`
FROM qsl_templates WHERE profile_id = ? OR profile_id IS NULL
ORDER BY is_default DESC, id DESC`, profileID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Record
for rows.Next() {
rec, err := scanRecord(rows)
if err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
// Get returns one template by ID, or sql.ErrNoRows if missing. // Get returns one template by ID, or sql.ErrNoRows if missing.
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) { func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+`
@@ -124,10 +146,15 @@ func (r *Repo) SetDefault(ctx context.Context, id int64) error {
// DefaultFor returns the best template for a profile: its own default, then // DefaultFor returns the best template for a profile: its own default, then
// any profile-less default, then the newest template. sql.ErrNoRows if none. // any profile-less default, then the newest template. sql.ErrNoRows if none.
func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) { func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) {
// Only the profile's OWN templates or shared (NULL) ones — never another
// profile's, so a profile with no default doesn't inherit some other
// profile's newest template (the "default looked global" bug).
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates
WHERE profile_id = ? OR profile_id IS NULL
ORDER BY (is_default = 1 AND profile_id = ?) DESC, ORDER BY (is_default = 1 AND profile_id = ?) DESC,
(is_default = 1 AND profile_id IS NULL) DESC, (is_default = 1 AND profile_id IS NULL) DESC,
id DESC LIMIT 1`, profileID) (profile_id = ?) DESC,
id DESC LIMIT 1`, profileID, profileID, profileID)
return scanRecord(row) return scanRecord(row)
} }
+1
View File
@@ -194,6 +194,7 @@ type QSOBox struct {
H float64 `json:"h"` H float64 `json:"h"`
BG string `json:"bg"` BG string `json:"bg"`
BGOpacity float64 `json:"bg_opacity"` BGOpacity float64 `json:"bg_opacity"`
FG string `json:"fg,omitempty"` // text colour (default dark)
Radius float64 `json:"radius"` Radius float64 `json:"radius"`
Title string `json:"title"` Title string `json:"title"`
Fields []string `json:"fields"` Fields []string `json:"fields"`
+49
View File
@@ -0,0 +1,49 @@
package qso
import (
"testing"
"time"
)
func tm(s string) time.Time {
t, _ := time.Parse("2006-01-02T15:04", s)
return t
}
func TestMatchIndexWindowAndMode(t *testing.T) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
// Local log: a CW QSO, an SSB (logged as USB) QSO, and an FT8 QSO.
idx.Add("4Z4DX", "15m", "CW", tm("2026-06-20T10:13"), 1)
idx.Add("V85NPV", "15m", "USB", tm("2025-04-18T17:01"), 2)
idx.Add("DK0SWL", "20m", "FT8", tm("2026-03-01T09:00"), 3)
win := 10 * time.Minute
cases := []struct {
name string
call, band, mode string
when string
wantID int64
wantOK bool
}{
{"exact", "4Z4DX", "15m", "CW", "2026-06-20T10:13", 1, true},
{"1-min drift", "4Z4DX", "15m", "CW", "2026-06-20T10:12", 1, true},
{"9-min drift ok", "4Z4DX", "15m", "CW", "2026-06-20T10:04", 1, true},
{"30-min drift misses", "4Z4DX", "15m", "CW", "2026-06-20T10:43", 0, false},
// Phone sidebands are interchangeable: an SSB confirmation matches a USB log.
{"SSB conf vs USB log", "V85NPV", "15m", "SSB", "2025-04-18T17:00", 2, true},
{"LSB conf vs USB log", "V85NPV", "15m", "LSB", "2025-04-18T17:00", 2, true},
// But a digital/phone mismatch does NOT match (exact for non-phone).
{"FT8 conf vs SSB log misses", "V85NPV", "15m", "FT8", "2025-04-18T17:00", 0, false},
// Digital is exact: FT8 matches FT8, but FT4 would not.
{"FT8 exact", "DK0SWL", "20m", "FT8", "2026-03-01T09:01", 3, true},
{"FT4 vs FT8 misses", "DK0SWL", "20m", "FT4", "2026-03-01T09:01", 0, false},
{"wrong band misses", "DK0SWL", "40m", "FT8", "2026-03-01T09:01", 0, false},
{"unknown call misses", "N0CALL", "15m", "CW", "2026-06-20T10:13", 0, false},
}
for _, c := range cases {
id, ok := idx.Match(c.call, c.band, c.mode, tm(c.when), win)
if ok != c.wantOK || (ok && id != c.wantID) {
t.Errorf("%s: got (id=%d ok=%v), want (id=%d ok=%v)", c.name, id, ok, c.wantID, c.wantOK)
}
}
}
+171
View File
@@ -490,6 +490,7 @@ type UploadRow struct {
// Manager may filter on (guards the dynamic column name in the query). // Manager may filter on (guards the dynamic column name in the query).
var uploadStatusCols = map[string]bool{ var uploadStatusCols = map[string]bool{
"lotw_sent": true, "lotw_sent": true,
"eqsl_sent": true,
"qrzcom_qso_upload_status": true, "qrzcom_qso_upload_status": true,
"clublog_qso_upload_status": true, "clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
@@ -723,6 +724,26 @@ var bulkEditableCols = map[string]bool{
"my_antenna": true, "my_antenna": true,
"my_sig": true, "my_sig": true,
"my_sig_info": true, "my_sig_info": true,
// Contest — the exchange/label fields that are constant across a run.
// (srx/stx serial numbers stay excluded: they are per-QSO.)
"contest_id": true,
"srx_string": true,
"stx_string": true,
"arrl_sect": true,
"precedence": true,
"class": true,
// Propagation / satellite — usually identical across a session.
"prop_mode": true,
"sat_name": true,
"sat_mode": true,
// Contacted station activation refs / SIG — meaningful to set across a
// selected batch (e.g. a park/summit worked repeatedly).
"pota_ref": true,
"sota_ref": true,
"wwff_ref": true,
"iota": true,
"sig": true,
"sig_info": true,
// Misc text // Misc text
"comment": true, "comment": true,
"notes": true, "notes": true,
@@ -990,6 +1011,32 @@ func FilterableFields() []string {
} }
// columnExpr resolves a filter field to a safe SQL expression — either a // columnExpr resolves a filter field to a safe SQL expression — either a
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
// offline safety file. It survives the ADIF round-trip and makes the replay
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
// the file", the next replay sees the id already in the log and skips it instead
// of creating a duplicate.
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"
// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
// in the logbook — the guard that makes replaying the safety file safe to repeat.
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
qid = strings.TrimSpace(qid)
if qid == "" {
return false, nil
}
expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
if db.IsMySQL() {
expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
}
var n int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// whitelisted column name or a json_extract over extras_json. // whitelisted column name or a json_extract over extras_json.
func columnExpr(field string) (string, bool) { func columnExpr(field string) (string, bool) {
f := strings.ToLower(strings.TrimSpace(field)) f := strings.ToLower(strings.TrimSpace(field))
@@ -1793,6 +1840,117 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err() return out, rows.Err()
} }
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
id int64
}
// MatchIndex matches a downloaded confirmation to a local QSO within a TIME
// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's
// logged time, which drifts a minute or two from ours. The MODE must match, with
// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may
// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
type MatchIndex struct {
byMode map[string][]matchRef // call|band|canonMode → refs
}
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
// mode is returned uppercased and unchanged (exact-match).
func canonMode(mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "SSB", "USB", "LSB":
return "SSB"
}
return m
}
func matchKeyMode(call, band, mode string) string {
return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode)
}
// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute
// precision as UTC. Zero time on failure (never matched).
func parseQSODate(s string) time.Time {
if len(s) >= 16 {
if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil {
return t
}
}
return time.Time{}
}
// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode)
// with its time, for windowed confirmation matching. When ownerCall is non-empty
// the index is SCOPED to QSOs whose station_callsign is that call (or blank —
// legacy QSOs that predate station-callsign stamping), so a confirmation download
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
// another (e.g. TM2Q).
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
idx := &MatchIndex{byMode: map[string][]matchRef{}}
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
var args []any
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)`
args = append(args, oc)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return idx, err
}
defer rows.Close()
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return idx, err
}
idx.add(call, band, mode, parseQSODate(when), id)
}
return idx, rows.Err()
}
// add inserts a QSO into the index (also used to register a freshly-inserted
// "add not-found" QSO so later confirmations in the same batch can match it).
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
mk := matchKeyMode(call, band, mode)
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
}
// Add registers a QSO in the index (exported wrapper for callers that inserted a
// not-found confirmation).
func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) {
idx.add(call, band, mode, when, id)
}
// Match returns the id of the local QSO closest in time to `when` (within
// `window`) for the given call/band/mode. The mode must match (phone sidebands
// folded together by canonMode). Ok is false when nothing is within the window.
func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) {
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
}
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
var best int64
bestD := window + time.Second
found := false
for _, rf := range refs {
if rf.when.IsZero() {
continue
}
d := when.Sub(rf.when)
if d < 0 {
d = -d
}
if d <= window && d < bestD {
bestD, best, found = d, rf.id, true
}
}
return best, found
}
// ConfirmedSets captures which DXCC / band / slot combinations are already // ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be // confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT. // flagged as a NEW DXCC / NEW BAND / NEW SLOT.
@@ -1870,6 +2028,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil return nil
} }
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark eqsl confirmed %d: %w", id, err)
}
return nil
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO // MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string. // after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
+667
View File
@@ -0,0 +1,667 @@
package qso
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// Statistics over the whole logbook.
//
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
// BYs. Two reasons: the date maths (year / month / hour of day) would need
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
// which is exactly the kind of thing that silently works on one backend and
// breaks on the other; and a single pass over a few columns of a 30k-row log is
// a few tens of milliseconds, so the complexity buys nothing.
// Bucket is one labelled count (mode, band, operator, entity…).
type Bucket struct {
Key string `json:"key"`
Count int `json:"count"`
}
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
// are the expensive minutes: they are where the score went.
type Gap struct {
Start string `json:"start"` // RFC3339 — the last QSO before the silence
End string `json:"end"` // the first QSO after it
Minutes int `json:"minutes"`
}
// ContestRun is one contest the operator actually took part in, discovered FROM
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
// the picker only ever offers contests you really entered, and never an empty one.
type ContestRun struct {
ID string `json:"id"`
Year int `json:"year"`
Count int `json:"count"`
Start string `json:"start"` // first QSO, RFC3339
End string `json:"end"` // last QSO
}
// ContestRuns lists every (contest, year) pair present in the logbook, most
// recent first.
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
if err != nil {
return nil, err
}
defer rows.Close()
type key struct {
id string
year int
}
agg := map[key]*ContestRun{}
for rows.Next() {
var id, dateStr sql.NullString
if err := rows.Scan(&id, &dateStr); err != nil {
return nil, err
}
cid := strings.ToUpper(strings.TrimSpace(id.String))
if cid == "" {
continue
}
t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() {
continue
}
k := key{cid, t.Year()}
c, ok := agg[k]
if !ok {
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
agg[k] = c
}
c.Count++
if t.Format(time.RFC3339) < c.Start {
c.Start = t.Format(time.RFC3339)
}
if t.Format(time.RFC3339) > c.End {
c.End = t.Format(time.RFC3339)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]ContestRun, 0, len(agg))
for _, c := range agg {
out = append(out, *c)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Year != out[j].Year {
return out[i].Year > out[j].Year // most recent first
}
return out[i].ID < out[j].ID
})
return out, nil
}
// gapThreshold is the silence that counts as "off the air". Short enough to catch
// a real break, long enough not to flag the normal pause between two QSOs.
const gapThreshold = 30 * time.Minute
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
// looks broken, which is exactly how it first shipped. Past this the UI says
// "period too long for an hourly chart" instead of drawing mush.
const rateMaxHours = 7 * 24
// Stats is the whole dashboard payload.
type Stats struct {
// Headline figures.
Total int `json:"total"`
UniqueCalls int `json:"unique_calls"`
Entities int `json:"entities"` // distinct DXCC entities
Continents int `json:"continents"` // distinct continents
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
LastQSO string `json:"last_qso"`
// Confirmations (of Total).
ConfirmedLoTW int `json:"confirmed_lotw"`
ConfirmedEQSL int `json:"confirmed_eqsl"`
ConfirmedQSL int `json:"confirmed_qsl"`
ConfirmedAny int `json:"confirmed_any"`
// Breakdowns, each sorted most → least (bands keep frequency order).
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
TopEntities []Bucket `json:"top_entities"`
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the
// requested [from,to] when given, else the span of the log.
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
WindowHours float64 `json:"window_hours"`
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
// On-air and off-air are a TIME BUDGET and must add up to the window:
// OnAirMinutes + OffAirMinutes == window
// The first version counted "clock hours containing at least one QSO" as on-air,
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
// measured on incompatible bases can't be compared, and the operator rightly
// didn't believe either of them.
OnAirMinutes int `json:"on_air_minutes"`
OffAirMinutes int `json:"off_air_minutes"`
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
PeakHourCount int `json:"peak_hour_count"`
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
// The contest RATE SHEET: hour by hour, who made the QSOs.
// RateOps are the operators, busiest first — that fixed order is also the
// colour/legend order, so an operator keeps their hue across the whole page.
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
// the per-operator numbers always sum to the hour's total.
RateOps []string `json:"rate_ops"`
RateByOp [][]int `json:"rate_by_op"`
}
// entry is one dated QSO with the operator who made it — the pair the contest
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
type entry struct {
t time.Time
op string
}
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
// so the band chart reads like a band plan instead of a jumble.
var bandOrder = map[string]int{
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
}
// yes reports whether an ADIF confirmation flag means "confirmed".
func yes(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "Y", "V": // V = verified (LoTW)
return true
}
return false
}
// Stats scans the logbook once and returns every breakdown the dashboard needs,
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
// the whole log).
//
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
// WHERE. qso_date is a text column whose format differs between the two backends,
// so a string comparison would quietly select the wrong rows on one of them. We
// already parse every date in this pass; filtering on the parsed value is both
// correct and free.
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
// it is set and no explicit window is given, the window becomes the contest's own
// span — so rate, best-hour and off-air figures are computed over the contest
// itself without the operator having to look its dates up.
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
var s Stats
contestID = strings.ToUpper(strings.TrimSpace(contestID))
rows, err := r.db.QueryContext(ctx, `
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
FROM qso`)
if err != nil {
return s, err
}
defer rows.Close()
var (
calls = map[string]struct{}{}
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
entityC = map[string]int{}
yearC = map[string]int{}
monthC = map[string]int{}
times []entry // every dated QSO (+ its operator), for the rate / gap maths
first, last time.Time
)
for rows.Next() {
var (
call, band, mode, cont, country sql.NullString
oper, station sql.NullString
lotw, eqsl, paper sql.NullString
dxcc sql.NullInt64
dateStr, contestID2 sql.NullString
)
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
return s, err
}
// Contest filter first — same reasoning as the window below: a QSO that
// isn't in this contest must not reach ANY bucket.
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
continue
}
// Window first: a QSO outside the period must not reach ANY bucket. Doing
// this after the counting (the obvious mistake) would leave the mode/band/
// operator charts showing the whole log while only the trend was filtered.
// parseTimeLoose is the repo's existing convention for qso_date — it copes
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
t := parseTimeLoose(dateStr.String).UTC()
dated := !t.IsZero()
if year > 0 && (!dated || t.Year() != year) {
continue
}
if !from.IsZero() && (!dated || t.Before(from)) {
continue
}
if !to.IsZero() && (!dated || t.After(to)) {
continue
}
s.Total++
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
calls[c] = struct{}{}
}
if dxcc.Valid && dxcc.Int64 > 0 {
entities[int(dxcc.Int64)] = struct{}{}
}
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
modeC[m]++
}
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
bandC[b]++
}
// An empty OPERATOR means "the station owner logged it himself" — bucket
// it explicitly rather than dropping the QSO from the operator chart.
op := strings.ToUpper(strings.TrimSpace(oper.String))
if op == "" {
op = "—"
}
opC[op]++
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
stationC[st]++
}
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
contC[c]++
}
if c := strings.TrimSpace(country.String); c != "" {
entityC[c]++
}
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
if cl {
s.ConfirmedLoTW++
}
if el {
s.ConfirmedEQSL++
}
if pl {
s.ConfirmedQSL++
}
if cl || el || pl {
s.ConfirmedAny++
}
// An undated QSO still counts in the mode/band/operator totals above, but
// it can't be placed on a time axis — leave it out of the trend rather than
// parking it at year zero.
if !dated {
continue
}
if first.IsZero() || t.Before(first) {
first = t
}
if last.IsZero() || t.After(last) {
last = t
}
yearC[t.Format("2006")]++
monthC[t.Format("2006-01")]++
times = append(times, entry{t: t, op: op})
}
if err := rows.Err(); err != nil {
return s, err
}
s.UniqueCalls = len(calls)
s.Entities = len(entities)
s.Continents = len(contC)
if !first.IsZero() {
s.FirstQSO = first.UTC().Format(time.RFC3339)
s.LastQSO = last.UTC().Format(time.RFC3339)
}
s.ByMode = topBuckets(modeC, 0)
s.ByOperator = topBuckets(opC, 0)
s.ByStation = topBuckets(stationC, 0)
s.ByContinent = topBuckets(contC, 0)
s.TopEntities = topBuckets(entityC, 15)
// Bands read in band-plan order, not by count — the shape of the chart IS
// the band plan, and re-sorting it by size would destroy that.
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
// chart would invent activity that never happened. A gap in the log is real
// information: it belongs on the chart as zeros.
s.ByYear = fillYears(yearC, first, last)
s.ByMonth = fillMonths(monthC, first, last)
s.periodMetrics(times, from, to, first, last)
s.ensureNonNil()
return s, nil
}
// ensureNonNil replaces every nil slice with an empty one.
//
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
// (Rate nil). The awards code carries the same guard for the same reason.
func (s *Stats) ensureNonNil() {
if s.ByMode == nil {
s.ByMode = []Bucket{}
}
if s.ByBand == nil {
s.ByBand = []Bucket{}
}
if s.ByOperator == nil {
s.ByOperator = []Bucket{}
}
if s.ByStation == nil {
s.ByStation = []Bucket{}
}
if s.ByContinent == nil {
s.ByContinent = []Bucket{}
}
if s.TopEntities == nil {
s.TopEntities = []Bucket{}
}
if s.ByYear == nil {
s.ByYear = []Bucket{}
}
if s.ByMonth == nil {
s.ByMonth = []Bucket{}
}
if s.Rate == nil {
s.Rate = []Bucket{}
}
if s.Gaps == nil {
s.Gaps = []Gap{}
}
if s.RateOps == nil {
s.RateOps = []string{}
}
if s.RateByOp == nil {
s.RateByOp = [][]int{}
}
}
// periodMetrics derives the rate / off-air figures that make a contest window
// readable. The window is the caller's [from,to] when given, else the span of the
// log itself.
//
// Two rates are reported on purpose, because a single one always flatters:
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
// how fast you go when you ARE at the radio.
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
if len(times) == 0 {
return
}
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
winStart, winEnd := from, to
if winStart.IsZero() {
winStart = first
}
if winEnd.IsZero() {
winEnd = last
}
if !winEnd.After(winStart) {
return
}
s.WindowStart = winStart.Format(time.RFC3339)
s.WindowEnd = winEnd.Format(time.RFC3339)
s.WindowHours = winEnd.Sub(winStart).Hours()
if s.WindowHours > 0 {
s.AvgPerHour = float64(len(times)) / s.WindowHours
}
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
hourly := map[string]int{}
for _, e := range times {
hourly[e.t.Format("2006-01-02 15")]++
}
for k, v := range hourly {
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
s.PeakHourKey, s.PeakHourCount = k, v
}
}
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
// actually quote. Two pointers over the sorted times: O(n).
lo := 0
for hi := range times {
for times[hi].t.Sub(times[lo].t) >= time.Hour {
lo++
}
if n := hi - lo + 1; n > s.Best60 {
s.Best60 = n
}
}
// Off-air is a TIME BUDGET, and it has to close on the window:
// OnAirMinutes + OffAirMinutes == window
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
// and the tail after the last, when an explicit window was asked for. Skipping
// those (the first version did) makes "on air" and "off air" sum to more than
// the window, and then neither number is believable.
addGap := func(a, b time.Time) {
d := b.Sub(a)
if d < gapThreshold {
return
}
s.OffAirMinutes += int(d.Minutes())
s.Gaps = append(s.Gaps, Gap{
Start: a.Format(time.RFC3339),
End: b.Format(time.RFC3339),
Minutes: int(d.Minutes()),
})
}
addGap(winStart, times[0].t) // lead-in
for i := 1; i < len(times); i++ { // the silences between QSOs
addGap(times[i-1].t, times[i].t)
}
addGap(times[len(times)-1].t, winEnd) // tail
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
if s.OnAirMinutes < 0 {
s.OnAirMinutes = 0
}
if s.OnAirMinutes > 0 {
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
}
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
if len(s.Gaps) > 10 {
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
}
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
// Every hour of the window, zeros included, so the silences read as silences.
if s.WindowHours > rateMaxHours {
return
}
// Operators, busiest first. That order is fixed and reused as the colour/legend
// order, so an operator keeps the same hue everywhere on the page — a chart that
// repaints its series when the filter changes is a chart nobody can trust.
opTotals := map[string]int{}
for _, e := range times {
opTotals[e.op]++
}
s.RateOps = make([]string, 0, len(opTotals))
for op := range opTotals {
s.RateOps = append(s.RateOps, op)
}
sort.Slice(s.RateOps, func(i, j int) bool {
a, b := s.RateOps[i], s.RateOps[j]
if opTotals[a] != opTotals[b] {
return opTotals[a] > opTotals[b]
}
return a < b
})
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
// is honest and still sums correctly.
const maxOps = 8
folded := false
if len(s.RateOps) > maxOps {
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
folded = true
}
opIdx := map[string]int{}
for i, op := range s.RateOps {
opIdx[op] = i
}
slotFor := func(op string) int {
if i, ok := opIdx[op]; ok {
return i
}
if folded {
return len(s.RateOps) - 1 // the "Other" bucket
}
return -1
}
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
// is how a rate sheet ends up not adding up to its own total row.
hourOps := map[string][]int{}
for _, e := range times {
k := e.t.Format("2006-01-02 15")
row, ok := hourOps[k]
if !ok {
row = make([]int, len(s.RateOps))
hourOps[k] = row
}
if i := slotFor(e.op); i >= 0 {
row[i]++
}
}
cur := winStart.Truncate(time.Hour)
end := winEnd.Truncate(time.Hour)
for !cur.After(end) {
k := cur.Format("2006-01-02 15")
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
row := hourOps[k]
if row == nil {
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
}
s.RateByOp = append(s.RateByOp, row)
cur = cur.Add(time.Hour)
}
}
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
// never the answer: under colour-blindness it is indistinguishable from one of the
// existing eight.
const otherOp = "Other"
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
// keeps only the top n.
func topBuckets(m map[string]int, n int) []Bucket {
out := make([]Bucket, 0, len(m))
for k, v := range m {
out = append(out, Bucket{Key: k, Count: v})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Key < out[j].Key
})
if n > 0 && len(out) > n {
out = out[:n]
}
return out
}
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
out := make([]Bucket, 0, len(keys))
for _, k := range keys {
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
// fillMonths emits EVERY month between the first and last QSO — zeros included —
// so the trend line's x-axis is real time rather than "months that happen to have
// data". A quiet decade must read as a decade at zero, not vanish.
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
for !cur.After(end) {
k := cur.Format("2006-01")
out = append(out, Bucket{Key: k, Count: m[k]})
cur = cur.AddDate(0, 1, 0)
}
return out
}
// fillYears does the same for the yearly view.
func fillYears(m map[string]int, first, last time.Time) []Bucket {
if first.IsZero() {
return nil
}
var out []Bucket
for y := first.Year(); y <= last.Year(); y++ {
k := fmt.Sprintf("%04d", y)
out = append(out, Bucket{Key: k, Count: m[k]})
}
return out
}
+228
View File
@@ -0,0 +1,228 @@
package qso
import (
"testing"
"time"
)
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
// alphabetically — the order of that chart IS the information.
func TestBandPlanOrder(t *testing.T) {
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
got := sortedBuckets(counts, func(a, b string) bool {
oa, ob := bandOrder[a], bandOrder[b]
if oa == 0 {
oa = 99
}
if ob == 0 {
ob = 99
}
if oa != ob {
return oa < ob
}
return a < b
})
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
if len(got) != len(want) {
t.Fatalf("got %d buckets, want %d", len(got), len(want))
}
for i := range want {
if got[i].Key != want[i] {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
}
}
}
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
// dashboard reads must therefore come back non-nil, even when empty.
func TestStatsNoNilSlices(t *testing.T) {
var s Stats // the worst case: nothing computed at all
s.ensureNonNil()
checks := map[string]bool{
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
}
for name, isNil := range checks {
if isNil {
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
}
}
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
// window that yields no hourly chart must still produce [] and not null.
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
var s2 Stats
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
s2.ensureNonNil()
if s2.Gaps == nil {
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
}
if len(s2.Gaps) != 0 {
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
}
}
// Contest metrics over a window. The two traps:
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
// a run straddling 13:4514:45 is invisible to clock-hour bucketing, and the
// rolling figure is the one contesters quote.
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
// 8-hour effort gets sold as a 48-hour score.
func TestContestPeriodMetrics(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
// then a 2-hour silence, then 3 more.
var times []entry
for i := 0; i < 10; i++ {
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
}
for i := 0; i < 3; i++ {
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
}
from := base // 12:00
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
var s Stats
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
if s.WindowHours != 6 {
t.Errorf("window = %.1f h, want 6", s.WindowHours)
}
// 13 QSOs over a 6 h window.
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
}
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
// would only ever see part of it.
if s.Best60 != 10 {
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
}
if s.PeakHourCount >= 10 {
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
}
// THE INVARIANT: on-air + off-air must close on the window. The first version
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
// incompatible bases; the operator believed neither, and was right.
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
}
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
// ignoring the lead-in and tail is what broke the budget.
if s.OffAirMinutes != 40+164+110 {
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
}
if len(s.Gaps) != 3 {
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
}
if s.AvgPerActive <= s.AvgPerHour {
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
}
// The rate timeline covers EVERY hour of the window, silences as zeros.
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
}
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
}
}
// The contest RATE SHEET: hour by hour, who made the QSOs.
//
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
// that hour's total. Derive the two separately and a rate sheet quietly stops
// adding up to its own total row — the sort of error nobody spots until someone
// checks the score by hand.
func TestRateSheetSumsToHourTotal(t *testing.T) {
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
times := []entry{
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
// hour 14 silent
{t: at(185), op: "F4BPO"}, // hour 15: 1
}
var s Stats
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
if len(s.Rate) != len(s.RateByOp) {
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
}
// Both operators present, busiest first (they tie at 3 → alphabetical).
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
}
for h := range s.Rate {
sum := 0
for _, n := range s.RateByOp[h] {
sum += n
}
if sum != s.Rate[h].Count {
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
s.Rate[h].Key, sum, s.Rate[h].Count)
}
if len(s.RateByOp[h]) != len(s.RateOps) {
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
}
}
// The silent hour is a row of zeros, not a missing row.
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
}
}
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
// would invent activity that never happened.
func TestTimeAxisIsContinuous(t *testing.T) {
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
if len(years) != 18 { // 2009..2026 inclusive
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
}
byKey := map[string]int{}
for _, b := range years {
byKey[b.Key] = b.Count
}
if byKey["2010"] != 0 || byKey["2018"] != 0 {
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
}
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
}
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
if len(months) != 207 {
t.Fatalf("months = %d, want 207 (continuous)", len(months))
}
if months[0].Key != "2009-05" || months[0].Count != 49 {
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
}
if months[len(months)-1].Key != "2026-07" {
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
}
// Every step is exactly one month — no jumps.
for i := 1; i < len(months); i++ {
prev, _ := time.Parse("2006-01", months[i-1].Key)
cur, _ := time.Parse("2006-01", months[i].Key)
if !prev.AddDate(0, 1, 0).Equal(cur) {
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
// Package solar fetches live space-weather / propagation numbers (solar flux,
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
package solar
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// feedURL is N0NBH's solar data as XML. Free, no key.
const feedURL = "https://www.hamqsl.com/solarxml.php"
// Data is the parsed snapshot exposed to the UI (strings — some fields read
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
// when stamping a QSO).
type Data struct {
SFI string `json:"sfi"` // solar flux index
SSN string `json:"ssn"` // sunspot number
AIndex string `json:"a_index"` // geomagnetic A index
KIndex string `json:"k_index"` // geomagnetic K index
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
Aurora string `json:"aurora"`
MUF string `json:"muf"`
Updated string `json:"updated"` // source timestamp text
Source string `json:"source"` // e.g. N0NBH
OK bool `json:"ok"` // last fetch succeeded
Error string `json:"error,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
}
// xmlSolar mirrors the hamqsl solarxml.php document.
type xmlSolar struct {
XMLName xml.Name `xml:"solar"`
SolarData struct {
Source string `xml:"source"`
Updated string `xml:"updated"`
SolarFlux string `xml:"solarflux"`
AIndex string `xml:"aindex"`
KIndex string `xml:"kindex"`
XRay string `xml:"xray"`
Sunspots string `xml:"sunspots"`
Aurora string `xml:"aurora"`
GeomagField string `xml:"geomagfield"`
MUF string `xml:"muf"`
} `xml:"solardata"`
}
// Fetch performs one GET + parse of the feed.
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return Data{}, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := client.Do(req)
if err != nil {
return Data{}, fmt.Errorf("solar: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Data{}, fmt.Errorf("solar: read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
}
return parseSolar(body)
}
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
// unit-tested without a network call).
func parseSolar(body []byte) (Data, error) {
var x xmlSolar
if err := xml.Unmarshal(body, &x); err != nil {
return Data{}, fmt.Errorf("solar: parse: %w", err)
}
s := x.SolarData
return Data{
SFI: strings.TrimSpace(s.SolarFlux),
SSN: strings.TrimSpace(s.Sunspots),
AIndex: strings.TrimSpace(s.AIndex),
KIndex: strings.TrimSpace(s.KIndex),
XRay: strings.TrimSpace(s.XRay),
GeomagField: strings.TrimSpace(s.GeomagField),
Aurora: strings.TrimSpace(s.Aurora),
MUF: strings.TrimSpace(s.MUF),
Updated: strings.TrimSpace(s.Updated),
Source: strings.TrimSpace(s.Source),
OK: true,
FetchedAt: time.Now(),
}, nil
}
// Manager caches the latest Data and refreshes it on a timer.
type Manager struct {
mu sync.RWMutex
data Data
client *http.Client
onChange func()
stop chan struct{}
once sync.Once
}
// NewManager builds a manager. onChange (optional) fires after each refresh so
// the host can push a UI event.
func NewManager(onChange func()) *Manager {
return &Manager{
client: &http.Client{Timeout: 20 * time.Second},
onChange: onChange,
stop: make(chan struct{}),
}
}
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
func (m *Manager) Start() {
go func() {
m.refresh()
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for {
select {
case <-m.stop:
return
case <-t.C:
m.refresh()
}
}
}()
}
// Stop ends the refresh loop.
func (m *Manager) Stop() {
if m == nil {
return
}
m.once.Do(func() { close(m.stop) })
}
// Get returns the latest snapshot.
func (m *Manager) Get() Data {
if m == nil {
return Data{}
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.data
}
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
func (m *Manager) Refresh() { m.refresh() }
func (m *Manager) refresh() {
d, err := Fetch(context.Background(), m.client)
m.mu.Lock()
if err != nil {
m.data.OK = false
m.data.Error = err.Error()
} else {
m.data = d
}
m.mu.Unlock()
if m.onChange != nil {
m.onChange()
}
}
+48
View File
@@ -0,0 +1,48 @@
package solar
import "testing"
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
const sampleXML = `<?xml version="1.0"?>
<solar>
<solardata>
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
<updated>08 Jul 2025 2100 GMT</updated>
<solarflux>170</solarflux>
<aindex>5</aindex>
<kindex>2</kindex>
<xray>C1.5</xray>
<sunspots>150</sunspots>
<geomagfield>QUIET</geomagfield>
<muf>18.5</muf>
</solardata>
</solar>`
func TestParseSolar(t *testing.T) {
d, err := parseSolar([]byte(sampleXML))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !d.OK {
t.Fatalf("expected OK")
}
checks := map[string]string{
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
}
want := map[string]string{
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
}
for k, got := range checks {
if got != want[k] {
t.Errorf("%s = %q, want %q", k, got, want[k])
}
}
}
func TestParseSolarBadXML(t *testing.T) {
if _, err := parseSolar([]byte("not xml")); err == nil {
t.Fatalf("expected an error for non-XML input")
}
}
+363
View File
@@ -0,0 +1,363 @@
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
// its "Transceiver Interface" serial protocol, reached either directly on a COM
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
// drive either behind one interface.
//
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
// note, the we7u/steppir library, and the la1k.no write-up — three independent
// sources that agree, which is what makes the byte layout trustworthy):
//
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
// <freq> = int32 big-endian of (Hz / 10)
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
// 'S' home/retract · 'V' calibrate
// STATUS: "?A" 0x0D → 11 bytes back:
// [2:6] int32 big-endian frequency (× 10 = Hz)
// [6] active-motor bitmask (0xFF = command received / setup)
// [7] & 0xE0 direction
//
// Timing: the controller needs ≥100 ms between commands and dislikes status
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
package steppir
import (
"encoding/binary"
"fmt"
"io"
"log"
"net"
"sync"
"time"
"go.bug.st/serial"
)
// Direction values, matching the app-wide convention (also used by Ultrabeam):
// 0 normal, 1 reverse (180°), 2 bidirectional.
const (
DirNormal = 0
Dir180 = 1
DirBi = 2
)
// SteppIR direction bytes on the wire.
const (
wireNormal = 0x00
wire180 = 0x40
wireBi = 0x80
)
// Transport says how to reach the controller.
type Transport struct {
Mode string // "tcp" | "serial"
Host string // tcp
Port int // tcp
COM string // serial device (COM3, /dev/ttyUSB0)
Baud int // serial baud (controller default 9600; 1200-19200 valid)
}
// Status is the antenna state, in the same shape the app reads from the
// Ultrabeam so the two are interchangeable at the UI.
type Status struct {
Connected bool `json:"connected"`
Frequency int `json:"frequency"` // kHz
Band int `json:"band"` // 0 (SteppIR does not report a band index)
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
MotorsMoving int `json:"motors_moving"`
}
type Client struct {
tr Transport
connMu sync.Mutex
conn io.ReadWriteCloser
statusMu sync.RWMutex
lastStatus *Status
lastSetKHz int
// A just-commanded direction is held until the controller's poll reports it —
// the motors take a second or two, and a stale poll would otherwise snap the
// UI back. Same trick as the Ultrabeam client.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
// frequency sets until it is turned back ON. Set on Retract, cleared by
// re-enabling on the next SetFrequency.
needAutotrack bool
stopChan chan struct{}
running bool
}
func New(tr Transport) *Client {
if tr.Baud <= 0 {
tr.Baud = 9600
}
return &Client{tr: tr, stopChan: make(chan struct{})}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stopChan)
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// LastSetKHz returns the frequency last commanded, or 0.
func (c *Client) LastSetKHz() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.lastSetKHz
}
func (c *Client) GetStatus() (*Status, error) {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
if c.lastStatus == nil {
return &Status{Connected: false}, nil
}
return c.lastStatus, nil
}
// open dials the transport. Callers hold connMu.
func (c *Client) open() (io.ReadWriteCloser, error) {
switch c.tr.Mode {
case "serial":
if c.tr.COM == "" {
return nil, fmt.Errorf("steppir: no serial port configured")
}
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
if err != nil {
return nil, err
}
// A finite read timeout so a silent controller doesn't wedge the poll loop.
_ = p.SetReadTimeout(2 * time.Second)
return p, nil
default: // tcp
if c.tr.Host == "" {
return nil, fmt.Errorf("steppir: no host configured")
}
d := net.Dialer{Timeout: 5 * time.Second}
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
}
}
func (c *Client) pollLoop() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.connMu.Lock()
if c.conn == nil {
conn, err := c.open()
if err != nil {
c.connMu.Unlock()
c.setDisconnected()
continue
}
c.conn = conn
}
c.connMu.Unlock()
st, err := c.queryStatus()
if err != nil {
log.Printf("steppir: status query failed, reconnecting: %v", err)
c.closeConn()
c.setDisconnected()
continue
}
st.Connected = true
c.statusMu.Lock()
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
st.Direction = c.pendingDir
}
}
c.lastStatus = st
c.statusMu.Unlock()
}
}
}
func (c *Client) setDisconnected() {
c.statusMu.Lock()
c.lastStatus = &Status{Connected: false}
c.statusMu.Unlock()
}
func (c *Client) closeConn() {
c.connMu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.connMu.Unlock()
}
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetDeadline(time.Now().Add(d))
}
}
func (c *Client) queryStatus() (*Status, error) {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return nil, fmt.Errorf("steppir: not connected")
}
setDeadline(conn, 3*time.Second)
if _, err := conn.Write([]byte("?A\r")); err != nil {
return nil, fmt.Errorf("write status cmd: %w", err)
}
buf := make([]byte, 11)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read status: %w", err)
}
return parseStatus(buf)
}
// parseStatus decodes an 11-byte status frame.
func parseStatus(b []byte) (*Status, error) {
if len(b) < 11 {
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
}
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
active := b[6]
dir := decodeDir(b[7])
// active==0xFF means "command just received" (not motion); the 0x01 bit is
// documented as always set. Treat anything else non-zero as motors busy.
moving := 0
if active != 0xFF && (active & ^byte(0x01)) != 0 {
moving = 1
}
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
}
func decodeDir(b byte) int {
switch b & 0xE0 {
case wireBi:
return DirBi
case wire180:
return Dir180
default:
return DirNormal
}
}
func dirWireByte(dir int) byte {
switch dir {
case Dir180:
return wire180
case DirBi:
return wireBi
default:
return wireNormal
}
}
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
func buildSet(freqHz int, dir int, cmd byte) []byte {
var f [4]byte
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
out := make([]byte, 0, 11)
out = append(out, '@', 'A')
out = append(out, f[:]...)
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
return out
}
func (c *Client) writeCmd(pkt []byte) error {
c.connMu.Lock()
conn := c.conn
c.connMu.Unlock()
if conn == nil {
return fmt.Errorf("steppir: not connected")
}
setDeadline(conn, 3*time.Second)
if _, err := conn.Write(pkt); err != nil {
c.closeConn()
return err
}
// The controller needs breathing room between commands.
time.Sleep(120 * time.Millisecond)
return nil
}
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
func (c *Client) SetFrequency(freqKhz int, direction int) error {
if c.needAutotrack {
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
return err
}
c.needAutotrack = false
}
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
return err
}
c.statusMu.Lock()
c.lastSetKHz = freqKhz
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.statusMu.Unlock()
return nil
}
// SetDirection changes the pattern. SteppIR has no standalone direction command —
// it is a SET with the current frequency and the new direction byte.
func (c *Client) SetDirection(direction int) error {
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil {
khz = st.Frequency
}
}
if khz <= 0 {
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
}
return c.SetFrequency(khz, direction)
}
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
// so the next SetFrequency re-enables it.
func (c *Client) Retract() error {
// A valid frequency must accompany the command; reuse the last one.
khz := c.LastSetKHz()
if khz <= 0 {
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
khz = st.Frequency
} else {
khz = 14000 // any in-range value; the controller just homes
}
}
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
return err
}
c.needAutotrack = true
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package steppir
import (
"encoding/binary"
"testing"
)
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
// antenna, far worse than a compile error.
func TestBuildSetLayout(t *testing.T) {
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
pkt := buildSet(14_074_000, DirNormal, '1')
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
if len(pkt) != 11 {
t.Fatalf("packet is %d bytes, want 11", len(pkt))
}
for i := range want {
if pkt[i] != want[i] {
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
}
}
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
t.Fatalf("freq round-trip = %d, want 14074000", got)
}
}
func TestBuildSetDirectionAndCommand(t *testing.T) {
cases := []struct {
dir int
cmd byte
wantDir byte
wantCmd byte
}{
{DirNormal, '1', 0x00, '1'},
{Dir180, '1', 0x40, '1'},
{DirBi, '1', 0x80, '1'},
{DirNormal, 'S', 0x00, 'S'}, // retract / home
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
}
for _, c := range cases {
pkt := buildSet(21_000_000, c.dir, c.cmd)
if pkt[7] != c.wantDir {
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
}
if pkt[8] != c.wantCmd {
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
}
}
}
// parseStatus decodes what the controller sends back — the inverse of buildSet's
// frequency field, plus the direction nibble.
func TestParseStatus(t *testing.T) {
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
st, err := parseStatus(frame)
if err != nil {
t.Fatal(err)
}
if st.Frequency != 14074 {
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
}
if st.Direction != Dir180 {
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
}
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
}
// Motors busy: a bit beyond 0x01 is set.
frame[6] = 0x07
st, _ = parseStatus(frame)
if st.MotorsMoving == 0 {
t.Error("active-motors 0x07 should read as moving")
}
// 0xFF is "command received", not motion.
frame[6] = 0xFF
st, _ = parseStatus(frame)
if st.MotorsMoving != 0 {
t.Error("active-motors 0xFF (command received) must not read as moving")
}
}
+25
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"log" "log"
"net" "net"
"runtime"
"sync" "sync"
"time" "time"
) )
@@ -75,6 +76,20 @@ type Client struct {
pendingDir int pendingDir int
pendingDirAt time.Time pendingDirAt time.Time
pendingDirSet bool pendingDirSet bool
// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
// deadband reference when the antenna's own status hasn't reported a frequency
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
// re-tunes the motors.
lastSetKHz int
}
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
// or 0 if none yet.
func (c *Client) LastSetKHz() int {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.lastSetKHz
} }
type Status struct { type Status struct {
@@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) {
// SetFrequency changes frequency and optional direction (command 3) // SetFrequency changes frequency and optional direction (command 3)
func (c *Client) SetFrequency(freqKhz int, direction int) error { func (c *Client) SetFrequency(freqKhz int, direction int) error {
// Trace WHO asked for the change — the caller's function + line — so an
// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
// to the follow loop, an immediate re-tune, or a direction re-issue.
caller := "?"
if pc, _, line, ok := runtime.Caller(1); ok {
caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
}
log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)
data := []byte{ data := []byte{
byte(freqKhz & 0xFF), byte(freqKhz & 0xFF),
byte((freqKhz >> 8) & 0xFF), byte((freqKhz >> 8) & 0xFF),
@@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
if err == nil { if err == nil {
c.statusMu.Lock() c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
c.lastSetKHz = freqKhz
if c.lastStatus != nil { if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately c.lastStatus.Direction = direction // reflect immediately
} }
+33 -3
View File
@@ -36,23 +36,53 @@ func profileArg(args []string) string {
} }
func main() { func main() {
// Single-instance guard: if OpsLog is already running, focus that window and
// exit instead of spawning a duplicate. A second process would open its own
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
// its own" when a windowless zombie instance was left running.
if !acquireSingleInstance() {
return
}
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()
app.startupProfile = profileArg(os.Args[1:]) app.startupProfile = profileArg(os.Args[1:])
// Restore the window's SIZE and maximised state at CREATION, from the geometry
// saved on last close. Doing it here (not after startup) is what makes the
// window open already at the right size instead of maximising then snapping
// smaller. Position can't be set through options, so it is applied while the
// window is still hidden (domReady) — invisible, so no jump. First run, or a
// window closed maximised, keeps the historical maximised default.
width, height := 1400, 900
startState := options.Maximised
if dataDir, err := userDataDir(); err == nil {
if ws, ok := readWindowState(dataDir); ok && !ws.Maximised &&
ws.Width >= 1100 && ws.Height >= 700 && ws.Width <= 8000 && ws.Height <= 6000 {
width, height = ws.Width, ws.Height
startState = options.Normal
}
}
// Create application with options // Create application with options
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "OpsLog", Title: "OpsLog",
Width: 1400, Width: width,
Height: 900, Height: height,
MinWidth: 1100, MinWidth: 1100,
MinHeight: 700, MinHeight: 700,
WindowStartState: options.Maximised, WindowStartState: startState,
// Start hidden and reveal only once the saved position has been applied and
// the DOM has painted (OnDomReady → domReady) — so the window appears
// already at its final size and position, with no post-launch jump.
StartHidden: true,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
Assets: assets, Assets: assets,
}, },
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1}, BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose, OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown, OnShutdown: app.shutdown,
Bind: []interface{}{ Bind: []interface{}{
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"context"
"fmt"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/db"
"hamlog/internal/offlineq"
"hamlog/internal/qso"
)
// ── Offline safety net ────────────────────────────────────────────────
//
// With the shared MySQL logbook, the network sits on the critical path of every
// write: lose it and you can't log at all. Rather than a fragile "fall back to
// SQLite" (which would leave you with a partial log and needs a restart), we
// keep the architecture exactly as it is and add a NET:
//
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
// DB back → replayed into the logbook, idempotently, file archived
//
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
// keeps it small. Accepted trade-off: while offline you don't see other ops'
// QSOs, and worked-before doesn't know about your own pending ones (they're
// listed separately in the UI instead).
// offlineReplayEvery is how often we re-probe the database while QSOs are
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
const offlineReplayEvery = 15 * time.Second
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
type OfflineStatus struct {
Offline bool `json:"offline"` // last write failed because the DB was unreachable
Pending int `json:"pending"` // QSOs sitting in the outbox
Path string `json:"path"` // where the outbox physically is
}
// queueOffline parks a QSO that couldn't reach the database. Returns false when
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
// outbox itself failed — in which case the caller MUST surface the original error
// rather than pretend the QSO was saved.
func (a *App) queueOffline(q qso.QSO, cause error) bool {
if a.offlineQ == nil || !db.IsMySQL() {
return false
}
qid, err := a.offlineQ.Append(q)
if err != nil {
// The net itself tore: do NOT claim success.
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
return false
}
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
a.offlineMode = true
a.emitOfflineStatus()
return true
}
// emitOfflineStatus pushes the banner state to the UI.
func (a *App) emitOfflineStatus() {
if a.ctx == nil || a.offlineQ == nil {
return
}
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
}
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
func (a *App) GetOfflineStatus() OfflineStatus {
if a.offlineQ == nil {
return OfflineStatus{}
}
n := a.offlineQ.Count()
return OfflineStatus{
Offline: a.offlineMode && n > 0,
Pending: n,
Path: a.offlineQ.Path(),
}
}
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
// what they logged while the database was down instead of flying blind.
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
if a.offlineQ == nil {
return nil, nil
}
return a.offlineQ.Pending()
}
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
// waiting for the next tick.
func (a *App) RetryOfflineSync() (int, error) {
return a.replayOfflineQueue()
}
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
// them the moment it answers.
func (a *App) offlineReplayLoop() {
t := time.NewTicker(offlineReplayEvery)
defer t.Stop()
for range t.C {
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
continue
}
if a.logDb == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := a.logDb.PingContext(ctx)
cancel()
if err != nil {
continue // still down — keep waiting, the QSOs are safe on disk
}
if n, rerr := a.replayOfflineQueue(); rerr != nil {
applog.Printf("offline: replay failed: %v", rerr)
} else if n > 0 {
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
}
}
}
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
// parked QSO carries a queue id, and one already present in the log is skipped —
// so a crash between "inserted" and "removed from the file" can never duplicate a
// contact. The file is archived BEFORE being cleared.
func (a *App) replayOfflineQueue() (int, error) {
if a.offlineQ == nil || a.qso == nil {
return 0, nil
}
pending, err := a.offlineQ.Pending()
if err != nil {
return 0, err
}
if len(pending) == 0 {
a.offlineMode = false
a.emitOfflineStatus()
return 0, nil
}
imported := 0
var failed []qso.QSO
for _, q := range pending {
qid := ""
if q.Extras != nil {
qid = q.Extras[qso.OfflineQueueKey]
}
// Idempotency guard: already replayed on an earlier (interrupted) pass?
if qid != "" {
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
imported++ // it IS in the log — treat as done, drop from the outbox
continue
} else if e != nil && db.IsConnLost(e) {
failed = append(failed, q) // DB went away again mid-replay
continue
}
}
if _, e := a.qso.Add(a.ctx, q); e != nil {
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
failed = append(failed, q)
continue
}
imported++
}
// Archive the outbox as it was, then keep only what still failed.
if imported > 0 {
if dst, e := a.offlineQ.Archive(); e != nil {
applog.Printf("offline: archive failed: %v", e)
} else if dst != "" {
applog.Printf("offline: outbox archived to %s", dst)
}
}
if e := a.offlineQ.Rewrite(failed); e != nil {
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
}
a.offlineMode = len(failed) > 0
a.emitOfflineStatus()
if imported > 0 && a.ctx != nil {
wruntime.EventsEmit(a.ctx, "logbook:changed")
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
}
return imported, nil
}
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
// very thing this design avoids.
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows || bindings
package main
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
// runs this binary, and a real OpsLog already running would otherwise make it
// exit before Wails could reflect the bindings.
func acquireSingleInstance() bool { return true }
+70
View File
@@ -0,0 +1,70 @@
//go:build windows && !bindings
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
// RUNNING this binary. With the guard active, a normal OpsLog already running on
// the dev machine holds the mutex, the generator's process exits instantly, and
// no bindings are produced. Excluding the guard from that build keeps generation
// working while shipping builds still get it.
package main
import (
"errors"
"unsafe"
"golang.org/x/sys/windows"
)
// singleInstanceName is a per-session named mutex. The Windows kernel releases
// it automatically when the owning process dies (even on a crash), so a
// lingering/zombie OpsLog can't permanently block future launches — killing it
// frees the name at once. Session-local (no "Global\\") = one instance per
// logged-in desktop, which is what we want.
const singleInstanceName = "OpsLog-SingleInstance-Mutex"
// acquireSingleInstance creates the named mutex. Returns ok=false when another
// OpsLog already holds it (this instance should exit); on the way out it brings
// the existing window to the front so a double-click just refocuses OpsLog
// instead of spawning a duplicate that fights over the CAT / antenna.
//
// The mutex handle is deliberately never closed — it must live for the whole
// process lifetime; the OS reclaims it on exit.
func acquireSingleInstance() (ok bool) {
namePtr, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return true // never block launch on an unexpected error
}
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
createMutex := kernel32.NewProc("CreateMutexW")
// CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName)
h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
if h == 0 {
return true // couldn't create the mutex → don't block the app
}
if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) {
focusExistingWindow()
return false
}
return true
}
// focusExistingWindow finds the running OpsLog window by its title and restores
// + foregrounds it. Best-effort; failures are silently ignored.
func focusExistingWindow() {
user32 := windows.NewLazySystemDLL("user32.dll")
findWindow := user32.NewProc("FindWindowW")
setForeground := user32.NewProc("SetForegroundWindow")
showWindow := user32.NewProc("ShowWindow")
title, err := windows.UTF16PtrFromString("OpsLog")
if err != nil {
return
}
hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title)))
if hwnd == 0 {
return
}
const swRestore = 9 // SW_RESTORE — un-minimise if needed
showWindow.Call(hwnd, swRestore)
setForeground.Call(hwnd)
}
+1 -1
View File
@@ -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.19" appVersion = "0.19.7"
// 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.
+23
View File
@@ -0,0 +1,23 @@
# Amplifiers and Switches
## PowerGenius XL (4O3A) amplifier
Direct TCP connection (Settings → PowerGenius):
- **Operate / Standby** and **fault** display.
- **Fan-mode** selector (Standard / Contest / Broadcast).
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
## Antenna Genius (4O3A) antenna switch
Over TCP / GSCP — a docked **A/B antenna-switch** widget:
- One row per configured antenna, with a **Port A** and **Port B** button.
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
that port is transmitting. Clicking a selected port deselects it (→ None).
- **Band filter** (funnel icon in the header): shows only the antennas configured
for the current band, like the native app. Toggle it off to see all antennas.
Antennas with an unknown band mask are always shown.
Configure the host / password in Settings → Antenna Genius.
+36
View File
@@ -0,0 +1,36 @@
# Audio and Keyers
## CW keyer
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
(Settings → CW Keyer):
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
the rig keys the sidetone but stays in RX.)
- **TCI** — for TCI backends.
## Digital Voice Keyer (DVK)
Record **F1F6** voice messages and transmit them. Set the audio devices, PTT
method and gains in Settings → Audio.
- **PTT** method: CAT, RTS, DTR, or none (VOX).
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
writes).
## QSO audio recording
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
format (WAV / MP3), pre-roll and gains in Settings → Audio.
## RX audio monitor (USB rigs)
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
audio for remote Icom is a work in progress — see
[[Remote Icom over the Internet]].)

Some files were not shown because too many files have changed in this diff Show More