External services (QRZ/Clublog/LoTW) + QSL Manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Vendored
+2
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user