feat: upload qrz.com clublog and lotw manually
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user