feat: added support for eQSL
This commit is contained in:
@@ -195,6 +195,12 @@ const (
|
|||||||
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
||||||
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
||||||
|
|
||||||
|
keyExtEQSLUsername = "extsvc.eqsl.username"
|
||||||
|
keyExtEQSLPassword = "extsvc.eqsl.password"
|
||||||
|
keyExtEQSLQTHNick = "extsvc.eqsl.qth_nickname"
|
||||||
|
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
||||||
|
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
||||||
|
|
||||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||||
|
|
||||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
@@ -4873,7 +4879,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode)
|
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||||
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -4930,6 +4937,19 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
out.HRDLog.Callsign = p.Callsign
|
out.HRDLog.Callsign = p.Callsign
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.EQSL = extsvc.ServiceConfig{
|
||||||
|
Username: m[keyExtEQSLUsername],
|
||||||
|
Password: m[keyExtEQSLPassword],
|
||||||
|
QTHNickname: m[keyExtEQSLQTHNick],
|
||||||
|
AutoUpload: m[keyExtEQSLAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtEQSLUploadMode]),
|
||||||
|
}
|
||||||
|
// Default the eQSL username to the active profile's call when unset.
|
||||||
|
if out.EQSL.Username == "" && a.profiles != nil {
|
||||||
|
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||||
|
out.EQSL.Username = p.Callsign
|
||||||
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4982,6 +5002,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if cfg.HRDLog.AutoUpload {
|
if cfg.HRDLog.AutoUpload {
|
||||||
hlAuto = "1"
|
hlAuto = "1"
|
||||||
}
|
}
|
||||||
|
eqMode := modeOf(cfg.EQSL.UploadMode)
|
||||||
|
eqAuto := "0"
|
||||||
|
if cfg.EQSL.AutoUpload {
|
||||||
|
eqAuto = "1"
|
||||||
|
}
|
||||||
scope := a.profileScope() // write under the active profile's prefix
|
scope := a.profileScope() // write under the active profile's prefix
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
@@ -5011,6 +5036,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
||||||
keyExtHRDLogAutoUpload: hlAuto,
|
keyExtHRDLogAutoUpload: hlAuto,
|
||||||
keyExtHRDLogUploadMode: hlMode,
|
keyExtHRDLogUploadMode: hlMode,
|
||||||
|
|
||||||
|
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
||||||
|
keyExtEQSLPassword: cfg.EQSL.Password,
|
||||||
|
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
||||||
|
keyExtEQSLAutoUpload: eqAuto,
|
||||||
|
keyExtEQSLUploadMode: eqMode,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -5044,6 +5075,11 @@ func (a *App) TestHRDLogUpload() (string, error) {
|
|||||||
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
|
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEQSLUpload validates the eQSL credentials with a real (no-op) request.
|
||||||
|
func (a *App) TestEQSLUpload() (string, error) {
|
||||||
|
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
||||||
|
}
|
||||||
|
|
||||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||||
|
|
||||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||||
@@ -5057,6 +5093,8 @@ func uploadColumnFor(service string) string {
|
|||||||
return "lotw_sent"
|
return "lotw_sent"
|
||||||
case extsvc.ServiceHRDLog:
|
case extsvc.ServiceHRDLog:
|
||||||
return "hrdlog_qso_upload_status"
|
return "hrdlog_qso_upload_status"
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
return "eqsl_sent"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -5197,6 +5235,8 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||||
case extsvc.ServiceHRDLog:
|
case extsvc.ServiceHRDLog:
|
||||||
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
|
||||||
default:
|
default:
|
||||||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||||
}
|
}
|
||||||
@@ -5770,6 +5810,8 @@ func (a *App) uploadOwnerCall(svc extsvc.Service) string {
|
|||||||
owner = cfg.Clublog.Callsign
|
owner = cfg.Clublog.Callsign
|
||||||
case extsvc.ServiceHRDLog:
|
case extsvc.ServiceHRDLog:
|
||||||
owner = cfg.HRDLog.Callsign
|
owner = cfg.HRDLog.Callsign
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
owner = cfg.EQSL.Username
|
||||||
}
|
}
|
||||||
owner = strings.ToUpper(strings.TrimSpace(owner))
|
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||||
if owner == "" && a.profiles != nil {
|
if owner == "" && a.profiles != nil {
|
||||||
@@ -5866,6 +5908,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
if strings.EqualFold(q.EQSLSent, "Y") {
|
||||||
|
applog.Printf("extsvc: QSO %d not eligible for eqsl — EQSLSent already %q (set Confirmations default to N to upload)", id, q.EQSLSent)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||||
if strings.EqualFold(q.LOTWSent, f) {
|
if strings.EqualFold(q.LOTWSent, f) {
|
||||||
@@ -5906,6 +5954,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
|||||||
applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err)
|
applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
if a.qso != nil {
|
||||||
|
if err := a.qso.MarkEQSLSent(a.ctx, id, date); err != nil {
|
||||||
|
applog.Printf("extsvc: mark eqsl sent %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtEQSLPassword: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const SERVICES = [
|
|||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
{ v: 'hrdlog', label: 'HRDLog.net' },
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
@@ -747,21 +747,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||||||
type ExtServiceCfg = {
|
type ExtServiceCfg = {
|
||||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||||
code: string;
|
code: string; qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string; station_location: string; key_password: string;
|
tqsl_path: string; station_location: string; key_password: string;
|
||||||
upload_flags: string[]; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '', code: '',
|
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
upload_flags: ['N', 'R'], write_log: false,
|
upload_flags: ['N', 'R'], write_log: false,
|
||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -771,10 +771,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [lotwTesting, setLotwTesting] = useState(false);
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -2607,8 +2609,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||||||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||||||
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
@@ -2679,6 +2680,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eqsl = extSvc.eqsl;
|
||||||
|
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||||
|
|
||||||
|
async function testEqsl() {
|
||||||
|
setEqslTesting(true);
|
||||||
|
setEqslTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestEQSLUpload();
|
||||||
|
setEqslTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setEqslTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setEqslTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lotw = extSvc.lotw;
|
const lotw = extSvc.lotw;
|
||||||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||||||
@@ -2913,6 +2932,72 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'eqsl' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Username (callsign)</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.username}
|
||||||
|
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="your eQSL.cc login (callsign)"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={eqsl.password}
|
||||||
|
onChange={(e) => setEqsl({ password: e.target.value })}
|
||||||
|
placeholder="eQSL.cc account password"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">QTH nickname</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.qth_nickname}
|
||||||
|
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
||||||
|
placeholder="optional — required only if your eQSL account has several QTHs"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
|
||||||
|
</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={eqsl.auto_upload}
|
||||||
|
onCheckedChange={(c) => setEqsl({ 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={eqsl.upload_mode || 'immediate'}
|
||||||
|
onValueChange={(v) => setEqsl({ 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>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {eqslTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{eqslTest && (
|
||||||
|
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{eqslTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'lotw' ? (
|
) : extSvcTab === 'lotw' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
|||||||
Vendored
+2
@@ -471,6 +471,8 @@ export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASy
|
|||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestEQSLUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEmail(arg1:string):Promise<void>;
|
export function TestEmail(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function TestHRDLogUpload():Promise<string>;
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|||||||
@@ -914,6 +914,10 @@ export function TestClublogUpload() {
|
|||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestEQSLUpload() {
|
||||||
|
return window['go']['main']['App']['TestEQSLUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestEmail(arg1) {
|
export function TestEmail(arg1) {
|
||||||
return window['go']['main']['App']['TestEmail'](arg1);
|
return window['go']['main']['App']['TestEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -670,6 +670,7 @@ export namespace extsvc {
|
|||||||
password: string;
|
password: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string;
|
tqsl_path: string;
|
||||||
station_location: string;
|
station_location: string;
|
||||||
@@ -691,6 +692,7 @@ export namespace extsvc {
|
|||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
this.code = source["code"];
|
this.code = source["code"];
|
||||||
|
this.qth_nickname = source["qth_nickname"];
|
||||||
this.force_station_callsign = source["force_station_callsign"];
|
this.force_station_callsign = source["force_station_callsign"];
|
||||||
this.tqsl_path = source["tqsl_path"];
|
this.tqsl_path = source["tqsl_path"];
|
||||||
this.station_location = source["station_location"];
|
this.station_location = source["station_location"];
|
||||||
@@ -706,6 +708,7 @@ export namespace extsvc {
|
|||||||
clublog: ServiceConfig;
|
clublog: ServiceConfig;
|
||||||
lotw: ServiceConfig;
|
lotw: ServiceConfig;
|
||||||
hrdlog: ServiceConfig;
|
hrdlog: ServiceConfig;
|
||||||
|
eqsl: ServiceConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -717,6 +720,7 @@ export namespace extsvc {
|
|||||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
|
||||||
|
// POST (or URL params) with the account credentials and the ADIF content.
|
||||||
|
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
|
||||||
|
|
||||||
|
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
|
||||||
|
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
|
||||||
|
|
||||||
|
// eqslPost performs the import POST and returns the raw response body. eQSL
|
||||||
|
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
|
||||||
|
// callers classify it via the markers below.
|
||||||
|
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("EQSL_USER", user)
|
||||||
|
form.Set("EQSL_PSWD", pswd)
|
||||||
|
form.Set("ADIFData", adif)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
|
func authErrEQSL(body string) string {
|
||||||
|
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
|
||||||
|
return "invalid username or password"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
|
||||||
|
// record when nick is set, so eQSL files the QSO under the right QTH profile
|
||||||
|
// (required when the account has more than one). ADIF field order is free, so
|
||||||
|
// prepending before the rest of the record is valid.
|
||||||
|
func eqslRecordWithNickname(record, nick string) string {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
|
||||||
|
// is the optional eQSL QTH nickname.
|
||||||
|
//
|
||||||
|
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
|
||||||
|
// "Bad record: Duplicate" for an already-present QSO (treated as success so
|
||||||
|
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
|
||||||
|
// credentials.
|
||||||
|
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
|
||||||
|
user = strings.ToUpper(strings.TrimSpace(user))
|
||||||
|
if user == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pswd) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
if strings.Contains(b, "duplicate") {
|
||||||
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
|
||||||
|
added, _ := strconv.Atoi(m[1])
|
||||||
|
if added >= 1 {
|
||||||
|
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
|
||||||
|
}
|
||||||
|
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||||
|
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||||
|
// (capped), else a generic phrase.
|
||||||
|
func eqslReason(body string) string {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
l := strings.TrimSpace(line)
|
||||||
|
ll := strings.ToLower(l)
|
||||||
|
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := strings.TrimSpace(body)
|
||||||
|
if b == "" {
|
||||||
|
return "upload rejected"
|
||||||
|
}
|
||||||
|
if len(b) > 200 {
|
||||||
|
b = b[:200]
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEQSL validates the configured eQSL credentials with a REAL request: it
|
||||||
|
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
|
||||||
|
// marker. Anything else means the credentials were accepted.
|
||||||
|
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||||
|
if user == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Password) == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
body, err := eqslPost(ctx, client, user, cfg.Password, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return "", fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Credentials accepted — %s", user), nil
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||||
|
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -67,6 +68,7 @@ type ServiceConfig struct {
|
|||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||||
Code string `json:"code"` // HRDLog: account upload code
|
Code string `json:"code"` // HRDLog: account upload code
|
||||||
|
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
@@ -84,6 +86,8 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
c.Email = strings.TrimSpace(c.Email)
|
c.Email = strings.TrimSpace(c.Email)
|
||||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||||
c.Code = strings.TrimSpace(c.Code)
|
c.Code = strings.TrimSpace(c.Code)
|
||||||
|
c.Username = strings.TrimSpace(c.Username)
|
||||||
|
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
|
||||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||||
@@ -115,6 +119,7 @@ type ExternalServices struct {
|
|||||||
Clublog ServiceConfig `json:"clublog"`
|
Clublog ServiceConfig `json:"clublog"`
|
||||||
LoTW ServiceConfig `json:"lotw"`
|
LoTW ServiceConfig `json:"lotw"`
|
||||||
HRDLog ServiceConfig `json:"hrdlog"`
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
cfg.Clublog = cfg.Clublog.normalised()
|
cfg.Clublog = cfg.Clublog.normalised()
|
||||||
cfg.LoTW = cfg.LoTW.normalised()
|
cfg.LoTW = cfg.LoTW.normalised()
|
||||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||||
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +157,10 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
|
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
|
||||||
m.route(ServiceHRDLog, id, h)
|
m.route(ServiceHRDLog, id, h)
|
||||||
}
|
}
|
||||||
|
// eQSL — needs the account username (callsign) + password.
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||||
|
m.route(ServiceEQSL, id, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// route sends a logged QSO down the configured timing path: queue it for the
|
// route sends a logged QSO down the configured timing path: queue it for the
|
||||||
@@ -198,6 +203,9 @@ func (m *Manager) onCloseServices() []Service {
|
|||||||
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
|
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
|
||||||
out = append(out, ServiceHRDLog)
|
out = append(out, ServiceHRDLog)
|
||||||
}
|
}
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||||
|
out = append(out, ServiceEQSL)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +259,12 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case ServiceEQSL:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.EQSL) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return uploaded
|
return uploaded
|
||||||
@@ -319,6 +333,8 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
owner = cfg.ForceStationCallsign
|
owner = cfg.ForceStationCallsign
|
||||||
case ServiceClublog, ServiceHRDLog:
|
case ServiceClublog, ServiceHRDLog:
|
||||||
owner = cfg.Callsign
|
owner = cfg.Callsign
|
||||||
|
case ServiceEQSL:
|
||||||
|
owner = cfg.Username
|
||||||
}
|
}
|
||||||
if owner != "" && m.deps.StationCallOf != nil {
|
if owner != "" && m.deps.StationCallOf != nil {
|
||||||
qcall := m.deps.StationCallOf(id)
|
qcall := m.deps.StationCallOf(id)
|
||||||
@@ -374,6 +390,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
|
case ServiceEQSL:
|
||||||
|
// eQSL keeps the QSO's own station call; the account is identified by
|
||||||
|
// the Username + Password, with an optional QTH nickname.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user