feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service
qso_realtime.php with the account's callbook credentials (which the external-services config falls back to when its own are blank), one ADIF record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400 rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403 credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close batch, right-click Send to, QSL Manager backlog, and a Test button that authenticates against the callbook login, which cannot touch the log. Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no branch in runManualUpload and fell through to QRZ.com — the selection was uploaded to the wrong service with the QRZ key. Both extras-stamped services now have their own branch.
This commit is contained in:
@@ -42,6 +42,7 @@ const SERVICES = [
|
||||
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||
{ v: 'lotw', label: 'LoTW' },
|
||||
{ v: 'hamlog', label: 'HAMLOG.online' },
|
||||
{ v: 'hamqth', label: 'HamQTH' },
|
||||
{ v: 'pota', label: 'POTA hunter log' },
|
||||
{ v: 'paper', label: 'Paper QSL' },
|
||||
];
|
||||
|
||||
@@ -35,6 +35,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
||||
{ service: 'eqsl', name: 'eQSL.cc' },
|
||||
{ service: 'lotw', name: 'LoTW' },
|
||||
{ service: 'hamlog', name: 'HAMLOG.online' },
|
||||
{ service: 'hamqth', name: 'HamQTH' },
|
||||
];
|
||||
|
||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
SetCIVTrace,
|
||||
CIVTraceEnabled,
|
||||
WinkeyerTraceEnabled,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetExternalServices, SaveExternalServices, TestHamQTHUpload, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
TestLoTWUpload, ListTQSLStationLocations,
|
||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||
@@ -1848,7 +1848,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// HRDLog only: publish the live frequency/mode/rig on hrdlog.net.
|
||||
on_air?: boolean;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; delete_remote?: boolean };
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg; hamlog: ExtServiceCfg; hamqth: ExtServiceCfg; delete_remote?: boolean };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||
@@ -1856,7 +1856,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
auto_upload: false, upload_mode: 'immediate', on_air: false,
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), delete_remote: false,
|
||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(), hamlog: emptyExtCfg(), hamqth: emptyExtCfg(), delete_remote: false,
|
||||
});
|
||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [qrzTesting, setQrzTesting] = useState(false);
|
||||
@@ -2025,6 +2025,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||
const [hamlogTest, setHamlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hamlogTesting, setHamlogTesting] = useState(false);
|
||||
const [hamqthTest, setHamqthTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [hamqthTesting, setHamqthTesting] = useState(false);
|
||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [eqslTesting, setEqslTesting] = useState(false);
|
||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||
@@ -2032,7 +2034,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
// could not hold hooks at all; PanelHost lifted that restriction, and this
|
||||
// stays put because moving it down would reset the tab on every reopen —
|
||||
// a choice now, not a workaround.
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'pota'>('qrz');
|
||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'hamlog' | 'hamqth' | 'pota'>('qrz');
|
||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||
const [potaToken, setPotaToken] = useState('');
|
||||
const [potaBusy, setPotaBusy] = useState(false);
|
||||
@@ -5928,6 +5930,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||
{ k: 'hamlog', label: 'HAMLOG.ONLINE', ready: true },
|
||||
{ k: 'hamqth', label: 'HAMQTH', ready: true },
|
||||
{ k: 'pota', label: 'POTA', ready: true },
|
||||
];
|
||||
|
||||
@@ -5997,6 +6000,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
}
|
||||
}
|
||||
|
||||
const hamqth = extSvc.hamqth ?? emptyExtCfg();
|
||||
const setHamqth = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, hamqth: { ...(s.hamqth ?? emptyExtCfg()), ...patch } }));
|
||||
async function testHamqth() {
|
||||
setHamqthTesting(true);
|
||||
setHamqthTest(null);
|
||||
try {
|
||||
const msg = await TestHamQTHUpload();
|
||||
setHamqthTest({ ok: true, msg });
|
||||
} catch (e: any) {
|
||||
setHamqthTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
setHamqthTesting(false);
|
||||
}
|
||||
}
|
||||
const hamlog = extSvc.hamlog ?? emptyExtCfg();
|
||||
const setHamlog = (patch: Partial<ExtServiceCfg>) =>
|
||||
setExtSvc((s) => ({ ...s, hamlog: { ...(s.hamlog ?? emptyExtCfg()), ...patch } }));
|
||||
@@ -6376,6 +6394,43 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'hamqth' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.username')}</Label>
|
||||
<Input value={hamqth.username} onChange={(e) => setHamqth({ username: e.target.value })} className="text-xs w-64" />
|
||||
<Label className="text-sm">{t('es.password')}</Label>
|
||||
<Input type="password" value={hamqth.password} onChange={(e) => setHamqth({ password: e.target.value })} className="text-xs w-64" />
|
||||
<Label className="text-sm">{t('es.hamqthCall')}</Label>
|
||||
<Input value={hamqth.callsign} onChange={(e) => setHamqth({ callsign: e.target.value.toUpperCase() })} className="text-xs w-40 font-mono" placeholder={t('es.optional')} />
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground -mt-1">{t('es.hamqthHint')}</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={hamqth.auto_upload} onCheckedChange={(c) => setHamqth({ auto_upload: !!c })} />
|
||||
{t('es.autoUpload')}
|
||||
</label>
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||
<Select value={hamqth.upload_mode === 'delayed' ? 'delayed' : 'immediate'} onValueChange={(v) => setHamqth({ upload_mode: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="outline" size="sm" onClick={testHamqth} disabled={hamqthTesting}>
|
||||
<UploadCloud className="size-3.5" /> {hamqthTesting ? t('es.testing') : t('es.testConn')}
|
||||
</Button>
|
||||
{hamqthTest && (
|
||||
<span className={cn('text-xs', hamqthTest.ok ? 'text-success' : 'text-danger')}>{hamqthTest.msg}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : extSvcTab === 'cloudlog' ? (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||
|
||||
Reference in New Issue
Block a user