diff --git a/app.go b/app.go index 1592b05..d7a02c5 100644 --- a/app.go +++ b/app.go @@ -100,13 +100,16 @@ const ( keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" keyExtClublogUploadMode = "extsvc.clublog.upload_mode" - keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" - keyExtLoTWStationLoc = "extsvc.lotw.station_location" - keyExtLoTWKeyPassword = "extsvc.lotw.key_password" - keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" - keyExtLoTWWriteLog = "extsvc.lotw.write_log" - keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" - keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" + keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" + keyExtLoTWStationLoc = "extsvc.lotw.station_location" + keyExtLoTWKeyPassword = "extsvc.lotw.key_password" + keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" + keyExtLoTWWriteLog = "extsvc.lotw.write_log" + keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" + 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 @@ -1327,7 +1330,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword, keyExtLoTWUploadFlag, keyExtLoTWWriteLog, - keyExtLoTWAutoUpload, keyExtLoTWUploadMode) + keyExtLoTWAutoUpload, keyExtLoTWUploadMode, + keyExtLoTWUsername, keyExtLoTWWebPassword) if err != nil { return out } @@ -1358,6 +1362,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { KeyPassword: m[keyExtLoTWKeyPassword], UploadFlag: m[keyExtLoTWUploadFlag], WriteLog: m[keyExtLoTWWriteLog] == "1", + Username: m[keyExtLoTWUsername], + Password: m[keyExtLoTWWebPassword], AutoUpload: m[keyExtLoTWAutoUpload] == "1", UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), } @@ -1432,6 +1438,8 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtLoTWWriteLog: ltWriteLog, keyExtLoTWAutoUpload: ltAuto, keyExtLoTWUploadMode: ltMode, + keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username), + keyExtLoTWWebPassword: cfg.LoTW.Password, } { if err := a.settings.Set(a.ctx, k, v); err != nil { 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, // for the LoTW settings dropdown. func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) { diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 2e3ff20..0daafd3 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { UploadCloud, Search, Loader2 } from 'lucide-react'; +import { UploadCloud, DownloadCloud, Search, Loader2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; @@ -9,7 +9,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; 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'; type UploadRow = { @@ -17,6 +17,11 @@ type UploadRow = { 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 = [ { v: 'qrz', label: 'QRZ.com' }, { v: 'clublog', label: 'Club Log' }, @@ -48,18 +53,30 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () const [selected, setSelected] = useState>(new Set()); const [searching, setSearching] = useState(false); 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([]); const [logOpen, setLogOpen] = useState(false); + const [logTitle, setLogTitle] = useState(''); + const [logAction, setLogAction] = useState<'upload' | 'download'>('upload'); const [logLines, setLogLines] = useState([]); const [uploadDone, setUploadDone] = useState(false); useEffect(() => { const offLog = EventsOn('qslmgr:log', (line: string) => setLogLines((p) => [...p, line])); 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); }); - return () => { offLog(); offDone(); }; + const offConf = EventsOn('qslmgr:confirmations', (list: any) => { + setConfirmations((list ?? []) as Confirmation[]); + setViewMode('confirmations'); + }); + return () => { offLog(); offDone(); offConf(); }; }, []); const selectedCount = selected.size; @@ -73,6 +90,7 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () const list = (r ?? []) as UploadRow[]; setRows(list); setSelected(new Set(list.map((x) => x.id))); // auto-select all found + setViewMode('upload'); } catch (e: any) { setError(String(e?.message ?? e)); setRows([]); @@ -98,6 +116,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () if (ids.length === 0) return; setLogLines([]); setUploadDone(false); + setLogAction('upload'); + setLogTitle('Uploading to ' + serviceLabel); setLogOpen(true); try { 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() { setLogOpen(false); - // Refresh the list so uploaded QSOs drop out of the current filter. - selectRequired(); + // After an upload, refresh the search so uploaded QSOs drop out of the + // 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]); @@ -150,14 +185,56 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: ()
- {rows.length} found · {selectedCount} selected + {viewMode === 'confirmations' + ? `${confirmations.length} confirmation(s) received` + : `${rows.length} found · ${selectedCount} selected`}
{/* Results grid */}
{error &&
{error}
} - {rows.length === 0 ? ( + + {viewMode === 'confirmations' ? ( + confirmations.length === 0 ? ( +
No new confirmations.
+ ) : ( + + + + + + + + + + + + + {confirmations.map((c, i) => ( + + + + + + + + + ))} + +
Date UTCCallsignBandModeCountryNew?
{fmtDate(c.qso_date)}{c.callsign}{c.band}{c.mode}{c.country} + {c.new_dxcc ? ( + NEW DXCC + ) : c.new_band ? ( + NEW BAND + ) : c.new_slot ? ( + NEW SLOT + ) : ( + + )} +
+ ) + ) : rows.length === 0 ? (
Pick a service + sent status, then “Select required”.
@@ -197,12 +274,24 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () )}
- - - + +
+ + +
+
+ + +
@@ -211,8 +300,8 @@ export function QSLManagerModal({ open, onClose }: { open: boolean; onClose: () { if (!o && uploadDone) closeLog(); }}> - Uploading to {serviceLabel} - Upload progress log. + {logTitle || 'Working…'} + Progress log.
{logLines.length === 0 ? ( diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 49ffe41..7c831c4 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -350,7 +350,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { // External services (logbook upload). One block per service; only QRZ is // wired today. upload_mode is 'immediate' | 'delayed' (per-service). 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; tqsl_path: string; station_location: string; key_password: string; 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 }; const emptyExtCfg = (): ExtServiceCfg => ({ - api_key: '', email: '', password: '', callsign: '', + api_key: '', email: '', username: '', password: '', callsign: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', upload_flag: 'R', write_log: false, auto_upload: false, upload_mode: 'immediate', @@ -2022,6 +2022,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { ) : extSvcTab === 'lotw' ? (
+ + setLotw({ username: e.target.value })} + placeholder="LoTW website login (for downloading confirmations)" + className="font-mono text-xs" + /> + + setLotw({ password: e.target.value })} + placeholder="LoTW website password" + className="text-xs" + /> ; export function DisconnectClusterServer(arg1:number):Promise; +export function DownloadConfirmations(arg1:string,arg2:boolean):Promise; + export function DuplicateProfile(arg1:number,arg2:string):Promise; export function ExportADIF(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 624ea53..ad55975 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -70,6 +70,10 @@ export function 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) { return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c549ed7..3531902 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -188,6 +188,7 @@ export namespace extsvc { export class ServiceConfig { api_key: string; email: string; + username: string; password: string; callsign: string; force_station_callsign: string; @@ -207,6 +208,7 @@ export namespace extsvc { if ('string' === typeof source) source = JSON.parse(source); this.api_key = source["api_key"]; this.email = source["email"]; + this.username = source["username"]; this.password = source["password"]; this.callsign = source["callsign"]; this.force_station_callsign = source["force_station_callsign"]; diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 24331b4..0f803b4 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -60,7 +60,8 @@ const ( type ServiceConfig struct { APIKey string `json:"api_key"` 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 ForceStationCallsign string `json:"force_station_callsign"` // QRZ TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index cac903d..3862475 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -4,6 +4,9 @@ import ( "context" "encoding/xml" "fmt" + "io" + "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -11,6 +14,60 @@ import ( "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), "") && !strings.Contains(strings.ToLower(text), "") { + 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 // 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 diff --git a/internal/qso/qso.go b/internal/qso/qso.go index b63a122..2534cc6 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1062,6 +1062,84 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string { 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. type scanner interface { Scan(dest ...any) error