feat: upload to external services clublog qrz
This commit is contained in:
+37
-4
@@ -35,7 +35,7 @@ import { BandMap } from '@/components/BandMap';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
|
||||
@@ -426,6 +426,10 @@ export default function App() {
|
||||
// already-worked). Otherwise only matching spots pass.
|
||||
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked';
|
||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
||||
const [clusterSearch, setClusterSearch] = useState('');
|
||||
const [showBandMap, setShowBandMap] = useState(false);
|
||||
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
|
||||
@@ -1279,8 +1283,7 @@ export default function App() {
|
||||
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
|
||||
value={callsign}
|
||||
onChange={(e) => onCallsignInput(e.target.value)}
|
||||
placeholder="F4XYZ"
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-24">
|
||||
<Label className="mb-1 h-3.5 flex items-center gap-1">Band <LockBtn k="band" title="band" /></Label>
|
||||
@@ -1443,7 +1446,7 @@ export default function App() {
|
||||
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Note (ADIF)</Label>
|
||||
<div className="flex flex-col flex-1 min-w-[120px]"><Label className="mb-1 h-3.5">Note</Label>
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1777,6 +1780,32 @@ export default function App() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-muted-foreground">Mode:</span>
|
||||
{([
|
||||
{ k: 'SSB' as SpotModeCat, label: 'SSB', cls: 'bg-sky-100 text-sky-800 border-sky-300' },
|
||||
{ k: 'CW' as SpotModeCat, label: 'CW', cls: 'bg-violet-100 text-violet-800 border-violet-300' },
|
||||
{ k: 'DATA' as SpotModeCat, label: 'DATA', cls: 'bg-emerald-100 text-emerald-800 border-emerald-300' },
|
||||
]).map((s) => {
|
||||
const on = clusterModeFilter.has(s.k);
|
||||
return (
|
||||
<button
|
||||
key={s.k}
|
||||
type="button"
|
||||
onClick={() => setClusterModeFilter((cur) => {
|
||||
const n = new Set(cur);
|
||||
if (n.has(s.k)) n.delete(s.k); else n.add(s.k);
|
||||
return n;
|
||||
})}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded border text-[10px] font-bold tracking-wider transition-opacity',
|
||||
on ? s.cls : `${s.cls} opacity-40 hover:opacity-100`,
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={clusterGroup} onCheckedChange={(c) => setClusterGroup(!!c)} />
|
||||
@@ -1808,6 +1837,10 @@ export default function App() {
|
||||
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
|
||||
if (spotMode && mode && spotMode !== mode) return false;
|
||||
}
|
||||
if (clusterModeFilter.size > 0) {
|
||||
const cat = spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz));
|
||||
if (!cat || !clusterModeFilter.has(cat)) return false;
|
||||
}
|
||||
if (clusterStatusFilter.size > 0) {
|
||||
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
|
||||
const st = spotStatus[k]?.status || '';
|
||||
|
||||
@@ -160,13 +160,47 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
||||
filtered.push(s);
|
||||
}
|
||||
filtered.sort((a, b) => b.freq_khz - a.freq_khz);
|
||||
|
||||
// Desired pill-CENTRE Y for each spot = its true frequency's Y.
|
||||
const desired = filtered.map(
|
||||
(s) => TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH,
|
||||
);
|
||||
|
||||
// Non-overlapping label placement via isotonic regression (pool-
|
||||
// adjacent-violators). We want centres c_0 ≤ c_1 ≤ … with
|
||||
// c_{i+1} − c_i ≥ PILL_H, minimising the squared displacement from each
|
||||
// label's desired centre. Substituting q_i = c_i − i·PILL_H turns the
|
||||
// gap constraint into "q non-decreasing", which PAVA solves exactly in
|
||||
// one pass. The win over the old greedy push-down: a tight cluster is
|
||||
// centred on its mean, so its labels fan out symmetrically ABOVE and
|
||||
// below the frequency (Log4OM style) instead of all spilling downward.
|
||||
const n = filtered.length;
|
||||
type Block = { sum: number; count: number; start: number };
|
||||
const blocks: Block[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
// e_i = desired_i − i·PILL_H is the target for the substituted q.
|
||||
let cur: Block = { sum: desired[i] - i * PILL_H, count: 1, start: i };
|
||||
while (blocks.length > 0) {
|
||||
const prev = blocks[blocks.length - 1];
|
||||
if (prev.sum / prev.count <= cur.sum / cur.count) break;
|
||||
blocks.pop();
|
||||
cur = { sum: prev.sum + cur.sum, count: prev.count + cur.count, start: prev.start };
|
||||
}
|
||||
blocks.push(cur);
|
||||
}
|
||||
const centers = new Array<number>(n);
|
||||
for (const b of blocks) {
|
||||
const mean = b.sum / b.count; // optimal q for the whole block
|
||||
for (let i = b.start; i < b.start + b.count; i++) centers[i] = mean + i * PILL_H;
|
||||
}
|
||||
// Centres are non-decreasing, so centers[0] is the topmost. Shift the
|
||||
// whole set down by any overflow above the band edge so the first label
|
||||
// isn't clipped (preserves the ≥ PILL_H spacing).
|
||||
const shift = n > 0 ? Math.max(0, TOP_PAD - (centers[0] - PILL_H / 2)) : 0;
|
||||
|
||||
const out: Placed[] = [];
|
||||
let prevY = -Infinity;
|
||||
for (const s of filtered) {
|
||||
const fy = TOP_PAD + (1 - (s.freq_khz - lo) / span) * innerH;
|
||||
const ly = Math.max(fy, prevY + PILL_H);
|
||||
out.push({ spot: s, freqY: fy, labelY: ly });
|
||||
prevY = ly;
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({ spot: filtered[i], freqY: desired[i], labelY: centers[i] + shift - PILL_H / 2 });
|
||||
}
|
||||
const lastLabelBottom = out.length ? out[out.length - 1].labelY + PILL_H : 0;
|
||||
return {
|
||||
|
||||
@@ -284,6 +284,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
||||
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
||||
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
||||
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F>
|
||||
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ const COL_CATALOG: ColEntry[] = [
|
||||
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) },
|
||||
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 },
|
||||
|
||||
// ── Contest ──
|
||||
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||
ChevronDown, ChevronRight,
|
||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||
Compass, Wifi, Construction,
|
||||
Compass, Wifi, Construction, UploadCloud,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
ComputeStationInfo,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
@@ -167,6 +168,7 @@ type SectionId =
|
||||
| 'profiles'
|
||||
| 'operating'
|
||||
| 'confirmations'
|
||||
| 'external-services'
|
||||
| 'udp'
|
||||
| 'lookup'
|
||||
| 'lists-bands'
|
||||
@@ -190,6 +192,7 @@ const TREE: TreeNode[] = [
|
||||
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||
{ kind: 'item', label: 'Operating conditions (rigs & antennas)', id: 'operating' },
|
||||
{ kind: 'item', label: 'Confirmations (QSL / eQSL / LoTW defaults)', id: 'confirmations' },
|
||||
{ kind: 'item', label: 'External services (QRZ.com, Clublog, LoTW…)', id: 'external-services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -221,6 +224,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
profiles: 'Profiles',
|
||||
operating: 'Operating conditions',
|
||||
confirmations: 'Confirmations',
|
||||
'external-services': 'External services',
|
||||
lookup: 'Callsign Lookup',
|
||||
'lists-bands': 'Bands',
|
||||
'lists-modes': 'Modes & default RST',
|
||||
@@ -368,15 +372,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
qsl_sent: string; qsl_rcvd: string;
|
||||
lotw_sent: string; lotw_rcvd: string;
|
||||
eqsl_sent: string; eqsl_rcvd: string;
|
||||
clublog_status: string; hrdlog_status: string;
|
||||
clublog_status: string; hrdlog_status: string; qrzcom_status: string;
|
||||
};
|
||||
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
|
||||
qsl_sent: '', qsl_rcvd: '',
|
||||
lotw_sent: '', lotw_rcvd: '',
|
||||
eqsl_sent: '', eqsl_rcvd: '',
|
||||
clublog_status: '', hrdlog_status: '',
|
||||
clublog_status: '', hrdlog_status: '', qrzcom_status: '',
|
||||
});
|
||||
|
||||
// External services (logbook upload). One block per service; only QRZ is
|
||||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||||
type ExtServiceCfg = {
|
||||
api_key: string; email: string; password: string; callsign: string;
|
||||
force_station_callsign: string;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', password: '', callsign: '',
|
||||
force_station_callsign: '', auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(),
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
const [clublogTest, setClublogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [clublogTesting, setClublogTesting] = useState(false);
|
||||
// Active tab in the External Services panel — lifted here because
|
||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz');
|
||||
|
||||
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
|
||||
enabled: false, folder: '', rotation: 5, zip: false,
|
||||
last_backup_at: '', default_folder: '',
|
||||
@@ -450,9 +477,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [l, ls, c, ap, r, b, qd] = await Promise.all([
|
||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
@@ -463,6 +490,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setRotator(r);
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -582,6 +610,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveBackupSettings(backupCfg as any);
|
||||
await SaveQSLDefaults(qslDefaults as any);
|
||||
await SaveExternalServices(extSvc as any);
|
||||
await SetClusterAutoConnect(clusterAutoConnect);
|
||||
|
||||
setMsg('Settings saved.');
|
||||
@@ -1555,7 +1584,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Upload status fields (Clublog / HRDLog) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
||||
Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded.
|
||||
</div>
|
||||
{/* Clublog */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
@@ -1575,6 +1604,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
{/* QRZ.com */}
|
||||
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
|
||||
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label>
|
||||
{renderSelect('qrzcom_status', FULL_OPTIONS)}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1727,12 +1765,227 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalServicesPanel() {
|
||||
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
||||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
||||
{ k: 'eqsl', label: 'EQSL' },
|
||||
{ k: 'hamqth', label: 'HAMQTH' },
|
||||
{ k: 'lotw', label: 'LOTW' },
|
||||
];
|
||||
const qrz = extSvc.qrz;
|
||||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
|
||||
const clublog = extSvc.clublog;
|
||||
const setClublog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, clublog: { ...s.clublog, ...patch } }));
|
||||
|
||||
async function testQrz() {
|
||||
setQrzTesting(true);
|
||||
setQrzTest(null);
|
||||
try {
|
||||
// Persist first so the backend test reads the key just typed.
|
||||
await SaveExternalServices(extSvc as any);
|
||||
const msg = await TestQRZUpload();
|
||||
setQrzTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setQrzTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setQrzTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testClublog() {
|
||||
setClublogTesting(true);
|
||||
setClublogTest(null);
|
||||
try {
|
||||
await SaveExternalServices(extSvc as any);
|
||||
const msg = await TestClublogUpload();
|
||||
setClublogTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setClublogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setClublogTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="External services"
|
||||
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first)."
|
||||
/>
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-border mb-4">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.k}
|
||||
type="button"
|
||||
onClick={() => setExtSvcTab(t.k)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs font-semibold rounded-t-md border-x border-t -mb-px transition-colors',
|
||||
extSvcTab === t.k
|
||||
? 'bg-card border-border text-foreground'
|
||||
: 'bg-muted/40 border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{!t.ready && <span className="ml-1 text-[9px] opacity-60">soon</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{extSvcTab === 'qrz' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">API key</Label>
|
||||
<Input
|
||||
value={qrz.api_key}
|
||||
onChange={(e) => setQrz({ api_key: e.target.value })}
|
||||
placeholder="QRZ.com logbook API key (XXXX-XXXX-XXXX-XXXX)"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Force station callsign</Label>
|
||||
<Input
|
||||
value={qrz.force_station_callsign}
|
||||
onChange={(e) => setQrz({ force_station_callsign: e.target.value.toUpperCase() })}
|
||||
placeholder="e.g. F4BPO — optional"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||
QRZ.com discards station calls that differ from the one registered on the logbook.
|
||||
Setting your registered callsign here rewrites <span className="font-mono">STATION_CALLSIGN</span> on
|
||||
upload, so a QSO logged with a <span className="font-mono">/P</span> or <span className="font-mono">/QRP</span> suffix
|
||||
is still accepted. Note this also applies to QSOs made with a country prefix/suffix.
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={qrz.auto_upload}
|
||||
onCheckedChange={(c) => setQrz({ auto_upload: !!c })}
|
||||
/>
|
||||
Automatic upload on new QSO
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Upload timing</Label>
|
||||
<Select
|
||||
value={qrz.upload_mode || 'immediate'}
|
||||
onValueChange={(v) => setQrz({ upload_mode: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">Immediate</SelectItem>
|
||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testQrz} disabled={qrzTesting || !qrz.api_key}>
|
||||
<UploadCloud className="size-3.5" /> {qrzTesting ? 'Testing…' : 'Test connection'}
|
||||
</Button>
|
||||
{qrzTest && (
|
||||
<span className={cn('text-xs', qrzTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||
{qrzTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'clublog' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Account email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={clublog.email}
|
||||
onChange={(e) => setClublog({ email: e.target.value })}
|
||||
placeholder="your Club Log account email"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={clublog.password}
|
||||
onChange={(e) => setClublog({ password: e.target.value })}
|
||||
placeholder="Club Log account password"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Logbook callsign</Label>
|
||||
<Input
|
||||
value={clublog.callsign}
|
||||
onChange={(e) => setClublog({ callsign: e.target.value.toUpperCase() })}
|
||||
placeholder="defaults to the active profile's callsign"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
|
||||
Club Log uploads each QSO in real time using your account email, password and the
|
||||
logbook callsign — no API key needed for QSO upload.
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={clublog.auto_upload}
|
||||
onCheckedChange={(c) => setClublog({ auto_upload: !!c })}
|
||||
/>
|
||||
Automatic upload on new QSO
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">Upload timing</Label>
|
||||
<Select
|
||||
value={clublog.upload_mode || 'immediate'}
|
||||
onValueChange={(v) => setClublog({ upload_mode: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">Immediate</SelectItem>
|
||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testClublog} disabled={clublogTesting}>
|
||||
<UploadCloud className="size-3.5" /> {clublogTesting ? 'Testing…' : 'Test connection'}
|
||||
</Button>
|
||||
{clublogTest && (
|
||||
<span className={cn('text-xs', clublogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||
{clublogTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
|
||||
<Construction className="size-10 opacity-30" />
|
||||
<div className="text-sm font-semibold text-foreground/70">
|
||||
{TABS.find((t) => t.k === extSvcTab)?.label} — coming soon
|
||||
</div>
|
||||
<div className="text-xs">This external service isn't wired up yet.</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Map sections to their content + icon (for placeholder).
|
||||
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||
station: StationPanel,
|
||||
profiles: ProfilesPanel,
|
||||
operating: OperatingPanelWrapper,
|
||||
confirmations: ConfirmationsPanel,
|
||||
'external-services': ExternalServicesPanel,
|
||||
lookup: LookupPanel,
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
|
||||
@@ -59,6 +59,20 @@ export function inferSpotMode(comment: string, freqHz: number): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// spotModeCategory buckets the fine-grained mode from inferSpotMode into
|
||||
// the three families the cluster filter exposes: 'SSB' (phone: SSB/FM/AM),
|
||||
// 'CW', and 'DATA' (every digital mode). Returns '' when the mode is
|
||||
// unknown so callers can decide how to treat un-categorisable spots.
|
||||
export function spotModeCategory(mode: string): 'SSB' | 'CW' | 'DATA' | '' {
|
||||
const m = (mode || '').toUpperCase();
|
||||
if (m === '') return '';
|
||||
if (m === 'CW') return 'CW';
|
||||
if (m === 'SSB' || m === 'USB' || m === 'LSB' || m === 'FM' || m === 'AM') return 'SSB';
|
||||
// Everything else inferSpotMode can return (FT8/FT4/JS8/RTTY/PSK*/…/DATA)
|
||||
// is a digital mode.
|
||||
return 'DATA';
|
||||
}
|
||||
|
||||
// spotStatusKey is the cache key for ClusterSpotStatuses results. Must be
|
||||
// computed identically in the fetcher and every reader — including the
|
||||
// band map and the spot table — so a CW spot's status doesn't get looked
|
||||
|
||||
Vendored
+9
@@ -6,6 +6,7 @@ import {profile} from '../models';
|
||||
import {adif} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {operating} from '../models';
|
||||
import {udp} from '../models';
|
||||
import {lookup} from '../models';
|
||||
@@ -62,6 +63,8 @@ export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||
|
||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
@@ -122,6 +125,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||
|
||||
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
|
||||
|
||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||
|
||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||
|
||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||
@@ -152,8 +157,12 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
@@ -106,6 +106,10 @@ export function GetCtyDatInfo() {
|
||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||
}
|
||||
|
||||
export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
|
||||
export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
@@ -226,6 +230,10 @@ export function SaveClusterServer(arg1) {
|
||||
return window['go']['main']['App']['SaveClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function SaveExternalServices(arg1) {
|
||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||
}
|
||||
|
||||
export function SaveListsSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||
}
|
||||
@@ -286,10 +294,18 @@ export function SwitchCATRig(arg1) {
|
||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||
}
|
||||
|
||||
export function TestClublogUpload() {
|
||||
return window['go']['main']['App']['TestClublogUpload']();
|
||||
}
|
||||
|
||||
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,67 @@ export namespace cluster {
|
||||
|
||||
}
|
||||
|
||||
export namespace extsvc {
|
||||
|
||||
export class ServiceConfig {
|
||||
api_key: string;
|
||||
email: string;
|
||||
password: string;
|
||||
callsign: string;
|
||||
force_station_callsign: string;
|
||||
auto_upload: boolean;
|
||||
upload_mode: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ServiceConfig(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.api_key = source["api_key"];
|
||||
this.email = source["email"];
|
||||
this.password = source["password"];
|
||||
this.callsign = source["callsign"];
|
||||
this.force_station_callsign = source["force_station_callsign"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
}
|
||||
}
|
||||
export class ExternalServices {
|
||||
qrz: ServiceConfig;
|
||||
clublog: ServiceConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||
}
|
||||
|
||||
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 lookup {
|
||||
|
||||
export class Result {
|
||||
@@ -403,6 +464,7 @@ export namespace main {
|
||||
eqsl_rcvd: string;
|
||||
clublog_status: string;
|
||||
hrdlog_status: string;
|
||||
qrzcom_status: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new QSLDefaults(source);
|
||||
@@ -418,6 +480,7 @@ export namespace main {
|
||||
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||
this.clublog_status = source["clublog_status"];
|
||||
this.hrdlog_status = source["hrdlog_status"];
|
||||
this.qrzcom_status = source["qrzcom_status"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
@@ -847,6 +910,8 @@ export namespace qso {
|
||||
clublog_qso_upload_status?: string;
|
||||
hrdlog_qso_upload_date?: string;
|
||||
hrdlog_qso_upload_status?: string;
|
||||
qrzcom_qso_upload_date?: string;
|
||||
qrzcom_qso_upload_status?: string;
|
||||
contest_id?: string;
|
||||
srx?: number;
|
||||
stx?: number;
|
||||
@@ -950,6 +1015,8 @@ export namespace qso {
|
||||
this.clublog_qso_upload_status = source["clublog_qso_upload_status"];
|
||||
this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"];
|
||||
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
|
||||
this.qrzcom_qso_upload_date = source["qrzcom_qso_upload_date"];
|
||||
this.qrzcom_qso_upload_status = source["qrzcom_qso_upload_status"];
|
||||
this.contest_id = source["contest_id"];
|
||||
this.srx = source["srx"];
|
||||
this.stx = source["stx"];
|
||||
|
||||
Reference in New Issue
Block a user