External services (QRZ/Clublog/LoTW) + QSL Manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 00:52:10 +02:00
parent 33a7b6c4ac
commit 8f1ad126ac
9 changed files with 456 additions and 27 deletions
+105 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { UploadCloud, Search, Loader2 } from 'lucide-react';
import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
@@ -9,7 +9,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual } from '../../wailsjs/go/main/App';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
type UploadRow = {
@@ -17,6 +17,11 @@ type UploadRow = {
band: string; mode: string; country: string; status: string;
};
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
};
const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' },
@@ -48,18 +53,30 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const [selected, setSelected] = useState<Set<number>>(new Set());
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
// 'upload' shows the Select-required search results; 'confirmations' shows
// the rows returned by a Download.
const [viewMode, setViewMode] = useState<'upload' | 'confirmations'>('upload');
const [confirmations, setConfirmations] = useState<Confirmation[]>([]);
const [logOpen, setLogOpen] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false);
useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => {
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0} uploaded`]);
setLogLines((p) => [...p, `— Done: ${d?.uploaded ?? 0}/${d?.total ?? 0}`]);
setUploadDone(true);
});
return () => { offLog(); offDone(); };
const offConf = EventsOn('qslmgr:confirmations', (list: any) => {
setConfirmations((list ?? []) as Confirmation[]);
setViewMode('confirmations');
});
return () => { offLog(); offDone(); offConf(); };
}, []);
const selectedCount = selected.size;
@@ -73,6 +90,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const list = (r ?? []) as UploadRow[];
setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
setViewMode('upload');
} catch (e: any) {
setError(String(e?.message ?? e));
setRows([]);
@@ -98,6 +116,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
if (ids.length === 0) return;
setLogLines([]);
setUploadDone(false);
setLogAction('upload');
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true);
try {
await UploadQSOsManual(service, ids);
@@ -107,10 +127,25 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
}
}
async function download() {
setLogLines([]);
setUploadDone(false);
setLogAction('download');
setLogTitle('Downloading confirmations from ' + serviceLabel);
setLogOpen(true);
try {
await DownloadConfirmations(service, addNotFound);
} catch (e: any) {
setLogLines((p) => [...p, 'Error: ' + String(e?.message ?? e)]);
setUploadDone(true);
}
}
function closeLog() {
setLogOpen(false);
// Refresh the list so uploaded QSOs drop out of the current filter.
selectRequired();
// After an upload, refresh the search so uploaded QSOs drop out of the
// filter. After a download, leave the confirmations list on screen.
if (logAction === 'upload') selectRequired();
}
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
@@ -150,14 +185,56 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
</Button>
<div className="flex-1" />
<span className="text-xs text-muted-foreground">
{rows.length} found · {selectedCount} selected
{viewMode === 'confirmations'
? `${confirmations.length} confirmation(s) received`
: `${rows.length} found · ${selectedCount} selected`}
</span>
</div>
{/* Results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{rows.length === 0 ? (
{viewMode === 'confirmations' ? (
confirmations.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">No new confirmations.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th>
<th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">Country</th>
<th className="py-1.5 px-2">New?</th>
</tr>
</thead>
<tbody>
{confirmations.map((c, i) => (
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
<td className="py-1 px-2">
{c.new_dxcc ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-rose-100 text-rose-800 border border-rose-300">NEW DXCC</span>
) : c.new_band ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-amber-100 text-amber-800 border border-amber-300">NEW BAND</span>
) : c.new_slot ? (
<span className="inline-block px-1.5 py-px rounded text-[9px] font-bold bg-yellow-100 text-yellow-800 border border-yellow-300">NEW SLOT</span>
) : (
<span className="text-muted-foreground/50"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">
Pick a service + sent status, then Select required.
</div>
@@ -197,12 +274,24 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
)}
</div>
<DialogFooter className="px-4 py-3 border-t border-border">
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
<DialogFooter className="px-4 py-3 border-t border-border sm:justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} title="Fetch confirmations from the service and update received status">
<DownloadCloud className="size-3.5" />
Download confirmations
</Button>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer" title="Insert confirmed QSOs that aren't in your log yet">
<Checkbox checked={addNotFound} onCheckedChange={(c) => setAddNotFound(!!c)} />
Add not-found
</label>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -211,8 +300,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Uploading to {serviceLabel}</DialogTitle>
<DialogDescription className="sr-only">Upload progress log.</DialogDescription>
<DialogTitle>{logTitle || 'Working…'}</DialogTitle>
<DialogDescription className="sr-only">Progress log.</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-auto rounded-md border border-border bg-muted/30 p-2.5 font-mono text-[11px] space-y-0.5">
{logLines.length === 0 ? (
+17 -2
View File
@@ -350,7 +350,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
// 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;
api_key: string; email: string; username: string; password: string; callsign: string;
force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string;
upload_flag: string; write_log: boolean;
@@ -358,7 +358,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
};
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', password: '', callsign: '',
api_key: '', email: '', username: '', password: '', callsign: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flag: 'R', write_log: false,
auto_upload: false, upload_mode: 'immediate',
@@ -2022,6 +2022,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
) : extSvcTab === 'lotw' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">LoTW user</Label>
<Input
value={lotw.username}
onChange={(e) => setLotw({ username: e.target.value })}
placeholder="LoTW website login (for downloading confirmations)"
className="font-mono text-xs"
/>
<Label className="text-sm">LoTW password</Label>
<Input
type="password"
value={lotw.password}
onChange={(e) => setLotw({ password: e.target.value })}
placeholder="LoTW website password"
className="text-xs"
/>
<Label className="text-sm">TQSL path</Label>
<Input
value={lotw.tqsl_path}
+2
View File
@@ -45,6 +45,8 @@ export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
+4
View File
@@ -70,6 +70,10 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
}
export function DownloadConfirmations(arg1, arg2) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
}
export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
+2
View File
@@ -188,6 +188,7 @@ export namespace extsvc {
export class ServiceConfig {
api_key: string;
email: string;
username: string;
password: string;
callsign: string;
force_station_callsign: string;
@@ -207,6 +208,7 @@ export namespace extsvc {
if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"];
this.email = source["email"];
this.username = source["username"];
this.password = source["password"];
this.callsign = source["callsign"];
this.force_station_callsign = source["force_station_callsign"];