From e1f1ab492241973a2d1f9dc7a379482df1db1755 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 18 Jun 2026 12:16:39 +0200 Subject: [PATCH] fix: bug sending LoTW on close --- app.go | 160 +++++++++++++++++----- frontend/src/components/SettingsModal.tsx | 37 +++-- frontend/wailsjs/go/models.ts | 4 +- internal/extsvc/extsvc.go | 20 ++- internal/extsvc/manager.go | 103 +++++++++----- internal/qso/qso.go | 50 +++++++ 6 files changed, 282 insertions(+), 92 deletions(-) diff --git a/app.go b/app.go index 44f738a..8dc14ea 100644 --- a/app.go +++ b/app.go @@ -196,7 +196,8 @@ const ( keyExtLoTWStationLoc = "extsvc.lotw.station_location" keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert) keyExtLoTWKeyPassword = "extsvc.lotw.key_password" - keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" + keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags) + keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R") keyExtLoTWWriteLog = "extsvc.lotw.write_log" keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload" keyExtLoTWUploadMode = "extsvc.lotw.upload_mode" @@ -724,12 +725,13 @@ func (a *App) startup(ctx context.Context) { // from settings and host callbacks to build ADIF, stamp the upload // status and surface errors to the UI. a.extsvc = extsvc.NewManager(extsvc.Deps{ - BuildADIF: a.buildUploadADIF, - MarkUploaded: a.markExtUploaded, - NotifyError: a.notifyExtError, - ShouldUpload: a.extShouldUpload, - StationCallOf: a.stationCallOf, - Logf: applog.Printf, + BuildADIF: a.buildUploadADIF, + MarkUploaded: a.markExtUploaded, + NotifyError: a.notifyExtError, + ShouldUpload: a.extShouldUpload, + StationCallOf: a.stationCallOf, + CloseUploadIDs: a.closeUploadIDs, + Logf: applog.Printf, }) a.extsvc.SetConfig(a.loadExternalServices()) @@ -855,7 +857,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep { } } if a.extsvc != nil { - if n := a.extsvc.PendingCount(); n > 0 { + if n := a.extsvc.CloseUploadCount(); n > 0 { out = append(out, shutdownStep{ ID: "extsvc-upload", Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n), @@ -4822,6 +4824,31 @@ func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, e return out, nil } +// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the +// CSV (new multi-select), fall back to the legacy single flag, and default to +// N+R when nothing is configured (covers an imported ADIF still marked unsent). +func parseUploadFlags(csv, legacy string) []string { + add := func(dst []string, seen map[string]bool, raw string) []string { + for _, p := range strings.Split(raw, ",") { + f := strings.ToUpper(strings.TrimSpace(p)) + if (f == "N" || f == "R") && !seen[f] { + seen[f] = true + dst = append(dst, f) + } + } + return dst + } + seen := map[string]bool{} + out := add(nil, seen, csv) + if len(out) == 0 { + out = add(out, seen, legacy) + } + if len(out) == 0 { + return []string{"N", "R"} + } + return out +} + func (a *App) loadExternalServices() extsvc.ExternalServices { var out extsvc.ExternalServices if a.settings == nil { @@ -4838,7 +4865,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword, - keyExtLoTWUploadFlag, keyExtLoTWWriteLog, + keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog, keyExtLoTWAutoUpload, keyExtLoTWUploadMode, keyExtLoTWUsername, keyExtLoTWWebPassword) if err != nil { @@ -4870,12 +4897,15 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { StationLocation: m[keyExtLoTWStationLoc], ForceStationCallsign: m[keyExtLoTWForceCall], KeyPassword: m[keyExtLoTWKeyPassword], - UploadFlag: m[keyExtLoTWUploadFlag], + UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]), WriteLog: m[keyExtLoTWWriteLog] == "1", Username: m[keyExtLoTWUsername], Password: m[keyExtLoTWWebPassword], AutoUpload: m[keyExtLoTWAutoUpload] == "1", - UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]), + // LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO + // uploads), so the UI offers no other timing. Force it here so configs + // saved by older builds — which stored "immediate" — still batch at close. + UploadMode: extsvc.ModeOnClose, } // Default the TQSL path to the standard install location when unset, so // the field is pre-populated if TQSL is present. @@ -4896,34 +4926,35 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if a.settings == nil { return fmt.Errorf("db not initialized") } - mode := string(extsvc.ModeImmediate) - if cfg.QRZ.UploadMode == extsvc.ModeDelayed { - mode = string(extsvc.ModeDelayed) + // Preserve the chosen upload timing — including "on_close", which the LoTW + // batch flush at shutdown depends on. (A previous version collapsed anything + // that wasn't "delayed" to "immediate", silently disabling on-close upload.) + modeOf := func(m extsvc.UploadMode) string { + switch m { + case extsvc.ModeDelayed: + return string(extsvc.ModeDelayed) + case extsvc.ModeOnClose: + return string(extsvc.ModeOnClose) + default: + return string(extsvc.ModeImmediate) + } } + mode := modeOf(cfg.QRZ.UploadMode) auto := "0" if cfg.QRZ.AutoUpload { auto = "1" } - clMode := string(extsvc.ModeImmediate) - if cfg.Clublog.UploadMode == extsvc.ModeDelayed { - clMode = string(extsvc.ModeDelayed) - } + clMode := modeOf(cfg.Clublog.UploadMode) clAuto := "0" if cfg.Clublog.AutoUpload { clAuto = "1" } - ltMode := string(extsvc.ModeImmediate) - if cfg.LoTW.UploadMode == extsvc.ModeDelayed { - ltMode = string(extsvc.ModeDelayed) - } + ltMode := modeOf(cfg.LoTW.UploadMode) ltAuto := "0" if cfg.LoTW.AutoUpload { ltAuto = "1" } - ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag)) - if ltFlag != "N" && ltFlag != "R" { - ltFlag = "R" - } + ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",") ltWriteLog := "0" if cfg.LoTW.WriteLog { ltWriteLog = "1" @@ -4946,7 +4977,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation), keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)), keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword, - keyExtLoTWUploadFlag: ltFlag, + keyExtLoTWUploadFlags: ltFlags, keyExtLoTWWriteLog: ltWriteLog, keyExtLoTWAutoUpload: ltAuto, keyExtLoTWUploadMode: ltMode, @@ -5682,6 +5713,70 @@ func (a *App) stationCallOf(id int64) string { return strings.ToUpper(strings.TrimSpace(q.StationCallsign)) } +// closeUploadIDs returns the QSO ids to upload to a service at app close, +// scanning the whole logbook: LoTW matches the configured sent-status set +// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an +// imported ADIF (old QSOs still unsent) flush on close. +func (a *App) closeUploadIDs(svc extsvc.Service) []int64 { + if a.qso == nil { + return nil + } + col := uploadColumnFor(string(svc)) + if col == "" { + return nil + } + cfg := a.loadExternalServices() + + // owner is the callsign this logbook signs/uploads as. Each external + // logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q) + // we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would + // be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log + // logbook. Prefer the configured force/owner call; fall back to the active + // profile's callsign. + var statuses []string + owner := "" + switch svc { + case extsvc.ServiceLoTW: + statuses = cfg.LoTW.UploadFlags + if len(statuses) == 0 { + return nil + } + owner = cfg.LoTW.ForceStationCallsign + case extsvc.ServiceQRZ: + owner = cfg.QRZ.ForceStationCallsign + case extsvc.ServiceClublog: + owner = cfg.Clublog.Callsign + } + owner = strings.ToUpper(strings.TrimSpace(owner)) + if owner == "" && a.profiles != nil { + if p, perr := a.profiles.Active(a.ctx); perr == nil { + owner = strings.ToUpper(strings.TrimSpace(p.Callsign)) + } + } + + cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses) + if err != nil { + applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err) + return nil + } + out := make([]int64, 0, len(cands)) + skipped := 0 + for _, c := range cands { + // Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN + // is assumed to be ours (it gets signed/labelled as owner on upload), + // mirroring the per-QSO guard in extsvc.upload. + if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) { + out = append(out, c.ID) + } else { + skipped++ + } + } + if skipped > 0 { + applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner) + } + return out +} + // extShouldUpload reports whether a QSO is eligible for upload to a service, // based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW // uploads only QSOs whose lotw_sent matches the configured Upload flag @@ -5708,15 +5803,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { } return true case extsvc.ServiceLoTW: - flag := "R" - if a.settings != nil { - if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil { - if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" { - flag = v - } + for _, f := range a.loadExternalServices().LoTW.UploadFlags { + if strings.EqualFold(q.LOTWSent, f) { + return true } } - return strings.EqualFold(q.LOTWSent, flag) + return false } return false } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c7a9035..e593552 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -749,14 +749,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan 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; + upload_flags: string[]; write_log: boolean; auto_upload: boolean; upload_mode: string; }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ api_key: '', email: '', username: '', password: '', callsign: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', - upload_flag: 'R', write_log: false, + upload_flags: ['N', 'R'], write_log: false, auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ @@ -2892,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan placeholder="only if your certificate key has a password" className="text-xs" /> - +
- +
+ {(['N', 'R'] as const).map((f) => { + const flags = lotw.upload_flags ?? []; + const checked = flags.includes(f); + return ( + + ); + })} +
- Must match your default LoTW sent status in Confirmations, or new QSOs won't be picked up. + At app close, every QSO whose LoTW sent status is one of these is signed and + uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become + Y and won't be re-sent. Must include your default sent status from Confirmations.
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index af5bc00..3f62c59 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -673,7 +673,7 @@ export namespace extsvc { tqsl_path: string; station_location: string; key_password: string; - upload_flag: string; + upload_flags: string[]; write_log: boolean; auto_upload: boolean; upload_mode: string; @@ -693,7 +693,7 @@ export namespace extsvc { this.tqsl_path = source["tqsl_path"]; this.station_location = source["station_location"]; this.key_password = source["key_password"]; - this.upload_flag = source["upload_flag"]; + this.upload_flags = source["upload_flags"]; this.write_log = source["write_log"]; this.auto_upload = source["auto_upload"]; this.upload_mode = source["upload_mode"]; diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index e825534..5ed048a 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -69,7 +69,7 @@ type ServiceConfig struct { TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional) - UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R" + UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R" WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log AutoUpload bool `json:"auto_upload"` UploadMode UploadMode `json:"upload_mode"` @@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig { c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign)) c.TQSLPath = strings.TrimSpace(c.TQSLPath) c.StationLocation = strings.TrimSpace(c.StationLocation) - // Upload flag is the LoTW sent-status that marks a QSO ready to upload. - // Only "N" (no) and "R" (requested) are valid; default to "R". - if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" { - c.UploadFlag = uf - } else { - c.UploadFlag = "R" + // Upload flags are the LoTW sent-statuses that mark a QSO ready to upload. + // Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default + // here (the caller injects N+R when nothing is configured). + var flags []string + seen := map[string]bool{} + for _, f := range c.UploadFlags { + f = strings.ToUpper(strings.TrimSpace(f)) + if (f == "N" || f == "R") && !seen[f] { + seen[f] = true + flags = append(flags, f) + } } + c.UploadFlags = flags switch c.UploadMode { case ModeDelayed, ModeOnClose: // keep diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index e0746a3..07236e7 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool { return baseCall(a) == baseCall(b) } +// SameBaseCall is the exported form of sameBaseCall, so the host app can apply +// the same "same operator?" rule when filtering an on-close upload batch by the +// active logbook's callsign. +func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) } + // Deps are the host-app callbacks the Manager needs. Keeping them as // function fields decouples extsvc from the qso/adif/settings packages and // keeps the upload-scheduling logic testable. @@ -62,6 +67,13 @@ type Deps struct { // option would otherwise silently relabel it). "" → no station call known. StationCallOf func(id int64) string + // CloseUploadIDs returns the QSO ids to upload for a service when the app + // closes — scanning the WHOLE logbook, not just this session: LoTW returns + // rows whose lotw_sent matches the configured status set; QRZ/Club Log + // return anything not yet "Y". This is what makes an imported ADIF (old + // QSOs still marked unsent) upload on close. nil → nothing to do. + CloseUploadIDs func(svc Service) []int64 + // Logf is an optional diagnostic logger. Logf func(format string, args ...any) } @@ -72,10 +84,9 @@ type Deps struct { type Manager struct { deps Deps - mu sync.Mutex - cfg ExternalServices - rnd *rand.Rand - pending map[Service][]int64 // QSO ids queued for ModeOnClose upload + mu sync.Mutex + cfg ExternalServices + rnd *rand.Rand } func NewManager(deps Deps) *Manager { @@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager { deps: deps, // Seeded from the clock; the delay only needs to be unpredictable // enough to spread bursts, not cryptographically random. - rnd: rand.New(rand.NewSource(time.Now().UnixNano())), - pending: map[Service][]int64{}, + rnd: rand.New(rand.NewSource(time.Now().UnixNano())), } } @@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) { m.mu.Lock() defer m.mu.Unlock() cfg.QRZ = cfg.QRZ.normalised() + cfg.Clublog = cfg.Clublog.normalised() + cfg.LoTW = cfg.LoTW.normalised() m.cfg = cfg } @@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) { // app-close batch, or schedule an immediate / delayed upload. func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) { if cfg.UploadMode == ModeOnClose { - m.mu.Lock() - m.pending[svc] = append(m.pending[svc], id) - n := len(m.pending[svc]) - m.mu.Unlock() - m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n) + // Nothing to queue: on-close upload sweeps the whole logbook from the + // database at shutdown (see FlushOnClose), so this QSO is picked up by + // its sent-status then — no in-memory tracking needed. return } m.scheduleUpload(svc, id, cfg) @@ -166,47 +176,64 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) { go m.upload(svc, id, cfg) } -// PendingCount returns how many QSOs are queued for on-close upload across -// all services. The shutdown sequence uses it to decide whether to show the -// upload step. -func (m *Manager) PendingCount() int { - m.mu.Lock() - defer m.mu.Unlock() +// onCloseServices returns the services configured for on-close auto-upload, +// with the minimum credentials to actually run. +func (m *Manager) onCloseServices() []Service { + cfg := m.Config() + var out []Service + if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" { + out = append(out, ServiceQRZ) + } + if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" { + out = append(out, ServiceClublog) + } + if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" { + out = append(out, ServiceLoTW) + } + return out +} + +// CloseUploadCount returns how many QSOs across the whole logbook would be +// uploaded at app close (sum over every on-close service). The shutdown +// sequence uses it to decide whether to show the upload step and its label. +func (m *Manager) CloseUploadCount() int { + if m.deps.CloseUploadIDs == nil { + return 0 + } n := 0 - for _, ids := range m.pending { - n += len(ids) + for _, svc := range m.onCloseServices() { + n += len(m.deps.CloseUploadIDs(svc)) } return n } -// FlushOnClose uploads every queued QSO. Called from the shutdown sequence. -// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a -// single TQSL batch. Returns the number of QSOs uploaded successfully. +// FlushOnClose uploads every QSO due for an on-close push, scanning the whole +// logbook (not just this session). Called from the shutdown sequence. QRZ/Club +// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL +// batch. Returns the number of QSOs uploaded successfully. func (m *Manager) FlushOnClose() int { - m.mu.Lock() - pending := m.pending - m.pending = map[Service][]int64{} - cfg := m.cfg - m.mu.Unlock() - + if m.deps.CloseUploadIDs == nil { + return 0 + } + cfg := m.Config() uploaded := 0 - for svc, ids := range pending { + for _, svc := range m.onCloseServices() { + ids := m.deps.CloseUploadIDs(svc) if len(ids) == 0 { continue } switch svc { case ServiceLoTW: uploaded += m.flushLoTWBatch(ids, cfg.LoTW) - default: - var sc ServiceConfig - switch svc { - case ServiceQRZ: - sc = cfg.QRZ - case ServiceClublog: - sc = cfg.Clublog - } + case ServiceQRZ: for _, id := range ids { - if m.upload(svc, id, sc) { + if m.upload(svc, id, cfg.QRZ) { + uploaded++ + } + } + case ServiceClublog: + for _, id := range ids { + if m.upload(svc, id, cfg.Clublog) { uploaded++ } } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index b6343cc..fae158a 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa return out, rows.Err() } +// UploadCandidate is a QSO eligible for an on-close upload: its id plus its +// STATION_CALLSIGN, so the caller can keep only the rows that belong to the +// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not +// all be signed under one cert). +type UploadCandidate struct { + ID int64 + StationCallsign string +} + +// ListUploadCandidates returns QSOs eligible for an on-close upload to a +// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses +// is the set of sent-status values to treat as "to send" (e.g. N, R); rows +// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything +// whose upload status isn't yet "Y" qualifies. +func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) { + if !uploadStatusCols[column] { + return nil, fmt.Errorf("invalid upload column %q", column) + } + var where string + var args []any + if column == "lotw_sent" { + if len(statuses) == 0 { + return nil, nil + } + ph := make([]string, len(statuses)) + for i, s := range statuses { + ph[i] = "?" + args = append(args, strings.ToUpper(strings.TrimSpace(s))) + } + where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")" + } else { + where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'" + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...) + if err != nil { + return nil, fmt.Errorf("list upload candidates: %w", err) + } + defer rows.Close() + var out []UploadCandidate + for rows.Next() { + var c UploadCandidate + if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + // MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on // a QSO after a successful push to the QRZ.com logbook. date is an ADIF // YYYYMMDD string. Only the two QRZ columns are touched — no full-row