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
+182 -1
View File
@@ -107,6 +107,9 @@ const (
keyExtLoTWWriteLog = "extsvc.lotw.write_log" keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
keyExtLoTWUsername = "extsvc.lotw.username" // LoTW website login (download)
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
) )
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -1327,7 +1330,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword, keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
keyExtLoTWUploadFlag, keyExtLoTWWriteLog, keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode) keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword)
if err != nil { if err != nil {
return out return out
} }
@@ -1358,6 +1362,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
KeyPassword: m[keyExtLoTWKeyPassword], KeyPassword: m[keyExtLoTWKeyPassword],
UploadFlag: m[keyExtLoTWUploadFlag], UploadFlag: m[keyExtLoTWUploadFlag],
WriteLog: m[keyExtLoTWWriteLog] == "1", WriteLog: m[keyExtLoTWWriteLog] == "1",
Username: m[keyExtLoTWUsername],
Password: m[keyExtLoTWWebPassword],
AutoUpload: m[keyExtLoTWAutoUpload] == "1", AutoUpload: m[keyExtLoTWAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
} }
@@ -1432,6 +1438,8 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtLoTWWriteLog: ltWriteLog, keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto, keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode, keyExtLoTWUploadMode: ltMode,
keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username),
keyExtLoTWWebPassword: cfg.LoTW.Password,
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -1572,6 +1580,179 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
} }
// ConfirmationItem is one downloaded confirmation shown in the QSL Manager,
// with award-style NEW flags computed against the log's prior confirmations.
type ConfirmationItem struct {
Callsign string `json:"callsign"`
QSODate string `json:"qso_date"` // ISO UTC
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewSlot bool `json:"new_slot"`
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same
// "qslmgr:log"/"qslmgr:done" events as upload so the UI reuses one window.
func (a *App) DownloadConfirmations(service string, addNotFound bool) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
cfg := a.loadExternalServices()
go a.runDownloadConfirmations(svc, cfg, addNotFound)
return nil
}
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
done := func(matched, total int) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
}
}
ctx := context.Background()
matched, total, added := 0, 0, 0
switch svc {
case extsvc.ServiceLoTW:
since := ""
if a.settings != nil {
if m, e := a.settings.GetMany(ctx, keyExtLoTWLastDownload); e == nil {
since = m[keyExtLoTWLastDownload]
}
}
if since != "" {
emit("Downloading LoTW confirmations received since " + since + "…")
} else {
emit("Downloading all LoTW confirmations…")
}
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, since)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// Snapshot what's already confirmed so we can flag each incoming
// confirmation as a NEW DXCC / band / slot.
sets, _ := a.qso.ConfirmedSlots(ctx)
var items []ConfirmationItem
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
total++
date := rec["qslrdate"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
matched++
}
} else if addNotFound {
q.LOTWSent = "Y"
q.LOTWRcvd = "Y"
q.LOTWRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID // guard against dup records in the report
added++
}
}
// Build the result row + NEW flags (vs the pre-download snapshot),
// then fold this slot into the sets so a repeat in the same batch
// isn't flagged twice.
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band,
Mode: q.Mode,
Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
if addNotFound {
emit(fmt.Sprintf("Matched %d, added %d (of %d confirmed QSO(s))", matched, added, total))
} else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
}
// Remember today so the next pull is incremental.
if a.settings != nil {
_ = a.settings.Set(ctx, keyExtLoTWLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
}
done(matched+added, total)
}
// enrichContactedFromCty fills a QSO's contacted-station country/DXCC/zones
// from cty.dat (offline) — used when adding a not-found confirmation that
// only carries call/band/mode/date.
func (a *App) enrichContactedFromCty(q *qso.QSO) {
if a.dxcc == nil || q.Callsign == "" {
return
}
m, ok := a.dxcc.Lookup(q.Callsign)
if !ok || m.Entity == nil {
return
}
if q.Country == "" {
q.Country = m.Entity.Name
}
if q.Continent == "" {
q.Continent = m.Continent
}
if q.DXCC == nil {
if n := dxcc.EntityDXCC(m.Entity.Name); n != 0 {
q.DXCC = &n
}
}
if q.CQZ == nil && m.CQZone != 0 {
v := m.CQZone
q.CQZ = &v
}
if q.ITUZ == nil && m.ITUZone != 0 {
v := m.ITUZone
q.ITUZ = &v
}
}
// ListTQSLStationLocations returns the Station Locations defined in TQSL, // ListTQSLStationLocations returns the Station Locations defined in TQSL,
// for the LoTW settings dropdown. // for the LoTW settings dropdown.
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) { func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
+100 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { UploadCloud, Search, Loader2 } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -9,7 +9,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; 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'; import { EventsOn } from '../../wailsjs/runtime/runtime';
type UploadRow = { type UploadRow = {
@@ -17,6 +17,11 @@ type UploadRow = {
band: string; mode: string; country: string; status: string; 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 = [ const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' }, { v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' }, { 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 [selected, setSelected] = useState<Set<number>>(new Set());
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); 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 [logOpen, setLogOpen] = useState(false);
const [logTitle, setLogTitle] = useState('');
const [logAction, setLogAction] = useState<'upload' | 'download'>('upload');
const [logLines, setLogLines] = useState<string[]>([]); const [logLines, setLogLines] = useState<string[]>([]);
const [uploadDone, setUploadDone] = useState(false); const [uploadDone, setUploadDone] = useState(false);
useEffect(() => { useEffect(() => {
const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line]));
const offDone = EventsOn('qslmgr:done', (d: any) => { 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); 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; const selectedCount = selected.size;
@@ -73,6 +90,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as UploadRow[];
setRows(list); setRows(list);
setSelected(new Set(list.map((x) => x.id))); // auto-select all found setSelected(new Set(list.map((x) => x.id))); // auto-select all found
setViewMode('upload');
} catch (e: any) { } catch (e: any) {
setError(String(e?.message ?? e)); setError(String(e?.message ?? e));
setRows([]); setRows([]);
@@ -98,6 +116,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setLogLines([]);
setUploadDone(false); setUploadDone(false);
setLogAction('upload');
setLogTitle('Uploading to ' + serviceLabel);
setLogOpen(true); setLogOpen(true);
try { try {
await UploadQSOsManual(service, ids); 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() { function closeLog() {
setLogOpen(false); setLogOpen(false);
// Refresh the list so uploaded QSOs drop out of the current filter. // After an upload, refresh the search so uploaded QSOs drop out of the
selectRequired(); // 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]); 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> </Button>
<div className="flex-1" /> <div className="flex-1" />
<span className="text-xs text-muted-foreground"> <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> </span>
</div> </div>
{/* Results grid */} {/* Results grid */}
<div className="overflow-auto px-4 py-2 min-h-[200px]"> <div className="overflow-auto px-4 py-2 min-h-[200px]">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>} {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"> <div className="text-sm text-muted-foreground py-10 text-center">
Pick a service + sent status, then Select required. Pick a service + sent status, then Select required.
</div> </div>
@@ -197,12 +274,24 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
)} )}
</div> </div>
<DialogFooter className="px-4 py-3 border-t border-border"> <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 variant="ghost" size="sm" onClick={onClose}>Close</Button>
<Button size="sm" onClick={upload} disabled={selectedCount === 0}> <Button size="sm" onClick={upload} disabled={selectedCount === 0}>
<UploadCloud className="size-3.5" /> <UploadCloud className="size-3.5" />
Upload {selectedCount} to {serviceLabel} Upload {selectedCount} to {serviceLabel}
</Button> </Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -211,8 +300,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
<Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}> <Dialog open={logOpen} onOpenChange={(o) => { if (!o && uploadDone) closeLog(); }}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Uploading to {serviceLabel}</DialogTitle> <DialogTitle>{logTitle || 'Working…'}</DialogTitle>
<DialogDescription className="sr-only">Upload progress log.</DialogDescription> <DialogDescription className="sr-only">Progress log.</DialogDescription>
</DialogHeader> </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"> <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 ? ( {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 // External services (logbook upload). One block per service; only QRZ is
// 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; password: string; callsign: string; api_key: string; email: string; username: string; password: string; callsign: 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_flag: string; write_log: boolean; 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 }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', password: '', callsign: '', api_key: '', email: '', username: '', password: '', callsign: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flag: 'R', write_log: false, upload_flag: 'R', write_log: false,
auto_upload: false, upload_mode: 'immediate', auto_upload: false, upload_mode: 'immediate',
@@ -2022,6 +2022,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
) : 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">
<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> <Label className="text-sm">TQSL path</Label>
<Input <Input
value={lotw.tqsl_path} 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 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 DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExportADIF(arg1:string):Promise<adif.ExportResult>; 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); 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) { export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
+2
View File
@@ -188,6 +188,7 @@ export namespace extsvc {
export class ServiceConfig { export class ServiceConfig {
api_key: string; api_key: string;
email: string; email: string;
username: string;
password: string; password: string;
callsign: string; callsign: string;
force_station_callsign: string; force_station_callsign: string;
@@ -207,6 +208,7 @@ export namespace extsvc {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.api_key = source["api_key"]; this.api_key = source["api_key"];
this.email = source["email"]; this.email = source["email"];
this.username = source["username"];
this.password = source["password"]; this.password = source["password"];
this.callsign = source["callsign"]; this.callsign = source["callsign"];
this.force_station_callsign = source["force_station_callsign"]; this.force_station_callsign = source["force_station_callsign"];
+2 -1
View File
@@ -60,7 +60,8 @@ const (
type ServiceConfig struct { type ServiceConfig struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ ForceStationCallsign string `json:"force_station_callsign"` // QRZ
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
+57
View File
@@ -4,6 +4,9 @@ import (
"context" "context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -11,6 +14,60 @@ import (
"time" "time"
) )
// lotwReportURL is LoTW's confirmation-report endpoint. It returns an ADIF
// document of the user's QSOs (optionally only confirmed ones).
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
// returned — used for incremental "Last download" updates.
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set")
}
q := url.Values{}
q.Set("login", user)
q.Set("password", cfg.Password)
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if s := strings.TrimSpace(since); s != "" {
q.Set("qso_qslsince", s)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err)
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err)
}
text := string(body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
}
// LoTW returns a plain-text error (not ADIF) on bad login.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text)
if len(msg) > 200 {
msg = msg[:200]
}
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
}
return text, nil
}
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no // LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
// plain HTTP API — every QSO must be signed with the station certificate // plain HTTP API — every QSO must be signed with the station certificate
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run // before LoTW accepts it. We write the QSO to a temporary ADIF file and run
+78
View File
@@ -1062,6 +1062,84 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode) return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
} }
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = id
}
return out, rows.Err()
}
// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode"
}
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y".
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`)
if err != nil {
return sets, err
}
defer rows.Close()
for rows.Next() {
var dxcc int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode); err != nil {
return sets, err
}
if dxcc == 0 {
continue
}
sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true
}
return sets, rows.Err()
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
}
return nil
}
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs. // scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface { type scanner interface {
Scan(dest ...any) error Scan(dest ...any) error