feat: upload qrz.com clublog and lotw manually
This commit is contained in:
@@ -27,6 +27,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { QSLManagerModal } from '@/components/QSLManagerModal';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
@@ -448,6 +449,7 @@ export default function App() {
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showQSLManager, setShowQSLManager] = useState(false);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
|
||||
@@ -505,6 +507,17 @@ export default function App() {
|
||||
}
|
||||
}, [filterCallsign, filterBand, filterMode, qsoLimit]);
|
||||
|
||||
// Refresh the Recent QSOs grid after external-service uploads stamp the
|
||||
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
|
||||
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
|
||||
useEffect(() => {
|
||||
let t: number | undefined;
|
||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||
const offDone = EventsOn('qslmgr:done', ping);
|
||||
return () => { offUploaded(); offDone(); if (t) window.clearTimeout(t); };
|
||||
}, [refresh]);
|
||||
|
||||
const loadStation = useCallback(async () => {
|
||||
try { setStation(await GetStationSettings()); } catch {}
|
||||
}, []);
|
||||
@@ -965,6 +978,8 @@ export default function App() {
|
||||
{ type: 'item', label: 'Clear filters', action: 'view.clearfilters' },
|
||||
]},
|
||||
{ name: 'tools', label: 'Tools', items: [
|
||||
{ type: 'item', label: 'QSL Manager…', action: 'tools.qslmanager' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Callsign lookup settings…', action: 'tools.lookup' },
|
||||
{ type: 'item', label: 'DX Cluster', action: 'tools.cluster', disabled: true },
|
||||
{ type: 'item', label: 'CAT control', action: 'tools.cat', disabled: true },
|
||||
@@ -989,6 +1004,7 @@ export default function App() {
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setShowQSLManager(true); break;
|
||||
case 'tools.lookup': setSettingsSection('lookup'); setShowSettings(true); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
}
|
||||
@@ -1979,6 +1995,8 @@ export default function App() {
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); }}
|
||||
/>
|
||||
)}
|
||||
<QSLManagerModal open={showQSLManager} onClose={() => setShowQSLManager(false)} />
|
||||
|
||||
{deletingQSO && (
|
||||
<ConfirmDialog
|
||||
title="Delete QSO?"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UploadCloud, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FindQSOsForUpload, UploadQSOsManual } from '../../wailsjs/go/main/App';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
type UploadRow = {
|
||||
id: number; qso_date: string; callsign: string;
|
||||
band: string; mode: string; country: string; status: string;
|
||||
};
|
||||
|
||||
const SERVICES = [
|
||||
{ v: 'qrz', label: 'QRZ.com' },
|
||||
{ v: 'clublog', label: 'Club Log' },
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
];
|
||||
|
||||
// Sent-status filter values. Empty string = blank/none.
|
||||
const SENT_STATUSES = [
|
||||
{ v: 'R', label: 'Requested' },
|
||||
{ v: 'N', label: 'No' },
|
||||
{ v: 'Q', label: 'Queued' },
|
||||
{ v: 'Y', label: 'Yes (already sent)' },
|
||||
{ v: 'I', label: 'Invalid' },
|
||||
{ v: '_', label: '— blank —' },
|
||||
];
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [service, setService] = useState('lotw');
|
||||
const [sent, setSent] = useState('R');
|
||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
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 —`]);
|
||||
setUploadDone(true);
|
||||
});
|
||||
return () => { offLog(); offDone(); };
|
||||
}, []);
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
|
||||
async function selectRequired() {
|
||||
setSearching(true);
|
||||
setError('');
|
||||
try {
|
||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||
const list = (r ?? []) as UploadRow[];
|
||||
setRows(list);
|
||||
setSelected(new Set(list.map((x) => x.id))); // auto-select all found
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
setRows([]);
|
||||
setSelected(new Set());
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(id)) n.delete(id); else n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
function toggleAll() {
|
||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
||||
if (ids.length === 0) return;
|
||||
setLogLines([]);
|
||||
setUploadDone(false);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
await UploadQSOsManual(service, ids);
|
||||
} 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();
|
||||
}
|
||||
|
||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-[1000px] w-full max-h-[88vh] grid grid-rows-[auto_auto_1fr_auto] gap-0 p-0">
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>QSL Manager</DialogTitle>
|
||||
<DialogDescription className="sr-only">Upload logged QSOs to online logbooks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Search toolbar */}
|
||||
<div className="flex items-end gap-3 px-4 py-3 border-b border-border bg-muted/20">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
|
||||
<Select value={service} onValueChange={setService}>
|
||||
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
|
||||
<Select value={sent} onValueChange={setSent}>
|
||||
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching}>
|
||||
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
|
||||
Select required
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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 ? (
|
||||
<div className="text-sm text-muted-foreground py-10 text-center">
|
||||
Pick a service + sent status, then “Select required”.
|
||||
</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 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
||||
<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">Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
||||
onClick={() => toggle(r.id)}
|
||||
>
|
||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
||||
</td>
|
||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
||||
<td className="py-1 px-2">{r.band}</td>
|
||||
<td className="py-1 px-2">{r.mode}</td>
|
||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Upload progress / log window */}
|
||||
<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>
|
||||
</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 ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting…</div>
|
||||
) : logLines.map((l, i) => (
|
||||
<div key={i} className={cn(l.includes('FAILED') || l.includes('failed') ? 'text-rose-700' : l.includes('OK') ? 'text-emerald-700' : 'text-foreground')}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button size="sm" onClick={closeLog} disabled={!uploadDone}>
|
||||
{uploadDone ? 'Close' : <><Loader2 className="size-3.5 animate-spin" /> Uploading…</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
ComputeStationInfo,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
@@ -154,10 +155,10 @@ const TREE: TreeNode[] = [
|
||||
{
|
||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||
{ 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' },
|
||||
{ kind: 'item', label: 'Profiles', id: 'profiles' },
|
||||
{ kind: 'item', label: 'Operating conditions', id: 'operating' },
|
||||
{ kind: 'item', label: 'Confirmations', id: 'confirmations' },
|
||||
{ kind: 'item', label: 'External services', id: 'external-services' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -351,20 +352,27 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
type ExtServiceCfg = {
|
||||
api_key: string; email: string; password: string; callsign: string;
|
||||
force_station_callsign: string;
|
||||
tqsl_path: string; station_location: string; key_password: string;
|
||||
upload_flag: string; write_log: boolean;
|
||||
auto_upload: boolean; upload_mode: string;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', password: '', callsign: '',
|
||||
force_station_callsign: '', auto_upload: false, upload_mode: 'immediate',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
upload_flag: 'R', write_log: false,
|
||||
auto_upload: false, upload_mode: 'immediate',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(),
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: 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);
|
||||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [lotwTesting, setLotwTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
// 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');
|
||||
@@ -456,6 +464,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
setBackupCfg(b as any);
|
||||
setQslDefaults(qd as any);
|
||||
setExtSvc(es as any);
|
||||
try {
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message ?? e));
|
||||
} finally {
|
||||
@@ -705,12 +717,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Latitude</Label>
|
||||
<Input type="number" step="0.0001" className="font-mono" value={(p as any).my_lat ?? ''}
|
||||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lat ?? ''}
|
||||
onChange={(e) => updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Longitude</Label>
|
||||
<Input type="number" step="0.0001" className="font-mono" value={(p as any).my_lon ?? ''}
|
||||
<Input type="number" step="0.001" className="font-mono" value={(p as any).my_lon ?? ''}
|
||||
onChange={(e) => updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1796,7 +1808,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
||||
{ k: 'eqsl', label: 'EQSL' },
|
||||
{ k: 'hamqth', label: 'HAMQTH' },
|
||||
{ k: 'lotw', label: 'LOTW' },
|
||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||
];
|
||||
const qrz = extSvc.qrz;
|
||||
const setQrz = (patch: Partial<ExtServiceCfg>) =>
|
||||
@@ -1834,6 +1846,33 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const lotw = extSvc.lotw;
|
||||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||||
|
||||
async function refreshLocations() {
|
||||
try {
|
||||
const locs: any = await ListTQSLStationLocations();
|
||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||
} catch (e: any) {
|
||||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function testLotw() {
|
||||
setLotwTesting(true);
|
||||
setLotwTest(null);
|
||||
try {
|
||||
await SaveExternalServices(extSvc as any);
|
||||
const msg = await TestLoTWUpload();
|
||||
setLotwTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setLotwTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setLotwTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
@@ -1880,13 +1919,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
/>
|
||||
</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
|
||||
@@ -1906,6 +1938,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">Immediate</SelectItem>
|
||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1950,11 +1983,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
/>
|
||||
</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
|
||||
@@ -1974,6 +2002,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">Immediate</SelectItem>
|
||||
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1990,6 +2019,80 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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">TQSL path</Label>
|
||||
<Input
|
||||
value={lotw.tqsl_path}
|
||||
onChange={(e) => setLotw({ tqsl_path: e.target.value })}
|
||||
placeholder="C:\Program Files (x86)\TrustedQSL\tqsl.exe"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Station location</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={lotw.station_location || '_'} onValueChange={(v) => setLotw({ station_location: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— pick a TQSL location —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stationLocations.length === 0 && <SelectItem value="_" disabled>No TQSL locations found</SelectItem>}
|
||||
{stationLocations.map((n) => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={refreshLocations} title="Reload locations from TQSL">
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm">Key password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={lotw.key_password}
|
||||
onChange={(e) => setLotw({ key_password: e.target.value })}
|
||||
placeholder="only if your certificate key has a password"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Upload flag</Label>
|
||||
<div>
|
||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
|
||||
</div>
|
||||
</div>
|
||||
</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={lotw.auto_upload}
|
||||
onCheckedChange={(c) => setLotw({ auto_upload: !!c, upload_mode: 'on_close' })}
|
||||
/>
|
||||
Automatic upload on application close
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={lotw.write_log}
|
||||
onCheckedChange={(c) => setLotw({ write_log: !!c })}
|
||||
/>
|
||||
Write TQSL diagnostic log (-t)
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testLotw} disabled={lotwTesting}>
|
||||
<UploadCloud className="size-3.5" /> {lotwTesting ? 'Testing…' : 'Test connection'}
|
||||
</Button>
|
||||
{lotwTest && (
|
||||
<span className={cn('text-xs', lotwTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||
{lotwTest.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" />
|
||||
|
||||
Vendored
+8
@@ -49,6 +49,8 @@ export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profil
|
||||
|
||||
export function ExportADIF(arg1:string):Promise<adif.ExportResult>;
|
||||
|
||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
@@ -91,6 +93,8 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
|
||||
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||
|
||||
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
|
||||
|
||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||
|
||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||
@@ -159,6 +163,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
export function TestClublogUpload():Promise<string>;
|
||||
|
||||
export function TestLoTWUpload():Promise<string>;
|
||||
|
||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||
|
||||
export function TestQRZUpload():Promise<string>;
|
||||
@@ -167,4 +173,6 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||
|
||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||
|
||||
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||
|
||||
@@ -78,6 +78,10 @@ export function ExportADIF(arg1) {
|
||||
return window['go']['main']['App']['ExportADIF'](arg1);
|
||||
}
|
||||
|
||||
export function FindQSOsForUpload(arg1, arg2) {
|
||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetActiveProfile() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
@@ -162,6 +166,10 @@ export function ListQSO(arg1) {
|
||||
return window['go']['main']['App']['ListQSO'](arg1);
|
||||
}
|
||||
|
||||
export function ListTQSLStationLocations() {
|
||||
return window['go']['main']['App']['ListTQSLStationLocations']();
|
||||
}
|
||||
|
||||
export function ListUDPIntegrations() {
|
||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||
}
|
||||
@@ -298,6 +306,10 @@ export function TestClublogUpload() {
|
||||
return window['go']['main']['App']['TestClublogUpload']();
|
||||
}
|
||||
|
||||
export function TestLoTWUpload() {
|
||||
return window['go']['main']['App']['TestLoTWUpload']();
|
||||
}
|
||||
|
||||
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
@@ -314,6 +326,10 @@ export function UpdateQSO(arg1) {
|
||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||
}
|
||||
|
||||
export function UploadQSOsManual(arg1, arg2) {
|
||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function WorkedBefore(arg1, arg2) {
|
||||
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,11 @@ export namespace extsvc {
|
||||
password: string;
|
||||
callsign: string;
|
||||
force_station_callsign: string;
|
||||
tqsl_path: string;
|
||||
station_location: string;
|
||||
key_password: string;
|
||||
upload_flag: string;
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
upload_mode: string;
|
||||
|
||||
@@ -205,6 +210,11 @@ export namespace extsvc {
|
||||
this.password = source["password"];
|
||||
this.callsign = source["callsign"];
|
||||
this.force_station_callsign = source["force_station_callsign"];
|
||||
this.tqsl_path = source["tqsl_path"];
|
||||
this.station_location = source["station_location"];
|
||||
this.key_password = source["key_password"];
|
||||
this.upload_flag = source["upload_flag"];
|
||||
this.write_log = source["write_log"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
}
|
||||
@@ -212,6 +222,7 @@ export namespace extsvc {
|
||||
export class ExternalServices {
|
||||
qrz: ServiceConfig;
|
||||
clublog: ServiceConfig;
|
||||
lotw: ServiceConfig;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ExternalServices(source);
|
||||
@@ -221,6 +232,7 @@ export namespace extsvc {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
@@ -241,6 +253,25 @@ export namespace extsvc {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class StationLocation {
|
||||
name: string;
|
||||
call: string;
|
||||
grid: string;
|
||||
dxcc: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationLocation(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.call = source["call"];
|
||||
this.grid = source["grid"];
|
||||
this.dxcc = source["dxcc"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1087,6 +1118,30 @@ export namespace qso {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class UploadRow {
|
||||
id: number;
|
||||
qso_date: string;
|
||||
callsign: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
country: string;
|
||||
status: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UploadRow(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.qso_date = source["qso_date"];
|
||||
this.callsign = source["callsign"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.country = source["country"];
|
||||
this.status = source["status"];
|
||||
}
|
||||
}
|
||||
export class WorkedEntry {
|
||||
id: number;
|
||||
// Go type: time
|
||||
|
||||
Reference in New Issue
Block a user