fix: bug sending LoTW on close

This commit is contained in:
2026-06-18 12:16:39 +02:00
parent b6d991b799
commit e1f1ab4922
6 changed files with 282 additions and 92 deletions
+126 -34
View File
@@ -196,7 +196,8 @@ const (
keyExtLoTWStationLoc = "extsvc.lotw.station_location" 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) 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" 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" 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"
@@ -724,12 +725,13 @@ func (a *App) startup(ctx context.Context) {
// from settings and host callbacks to build ADIF, stamp the upload // from settings and host callbacks to build ADIF, stamp the upload
// status and surface errors to the UI. // status and surface errors to the UI.
a.extsvc = extsvc.NewManager(extsvc.Deps{ a.extsvc = extsvc.NewManager(extsvc.Deps{
BuildADIF: a.buildUploadADIF, BuildADIF: a.buildUploadADIF,
MarkUploaded: a.markExtUploaded, MarkUploaded: a.markExtUploaded,
NotifyError: a.notifyExtError, NotifyError: a.notifyExtError,
ShouldUpload: a.extShouldUpload, ShouldUpload: a.extShouldUpload,
StationCallOf: a.stationCallOf, StationCallOf: a.stationCallOf,
Logf: applog.Printf, CloseUploadIDs: a.closeUploadIDs,
Logf: applog.Printf,
}) })
a.extsvc.SetConfig(a.loadExternalServices()) a.extsvc.SetConfig(a.loadExternalServices())
@@ -855,7 +857,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
} }
} }
if a.extsvc != nil { if a.extsvc != nil {
if n := a.extsvc.PendingCount(); n > 0 { if n := a.extsvc.CloseUploadCount(); n > 0 {
out = append(out, shutdownStep{ out = append(out, shutdownStep{
ID: "extsvc-upload", ID: "extsvc-upload",
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n), 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 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 { func (a *App) loadExternalServices() extsvc.ExternalServices {
var out extsvc.ExternalServices var out extsvc.ExternalServices
if a.settings == nil { if a.settings == nil {
@@ -4838,7 +4865,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign, keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode, keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword, keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
keyExtLoTWUploadFlag, keyExtLoTWWriteLog, keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode, keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
keyExtLoTWUsername, keyExtLoTWWebPassword) keyExtLoTWUsername, keyExtLoTWWebPassword)
if err != nil { if err != nil {
@@ -4870,12 +4897,15 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
StationLocation: m[keyExtLoTWStationLoc], StationLocation: m[keyExtLoTWStationLoc],
ForceStationCallsign: m[keyExtLoTWForceCall], ForceStationCallsign: m[keyExtLoTWForceCall],
KeyPassword: m[keyExtLoTWKeyPassword], KeyPassword: m[keyExtLoTWKeyPassword],
UploadFlag: m[keyExtLoTWUploadFlag], UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
WriteLog: m[keyExtLoTWWriteLog] == "1", WriteLog: m[keyExtLoTWWriteLog] == "1",
Username: m[keyExtLoTWUsername], Username: m[keyExtLoTWUsername],
Password: m[keyExtLoTWWebPassword], Password: m[keyExtLoTWWebPassword],
AutoUpload: m[keyExtLoTWAutoUpload] == "1", 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 // Default the TQSL path to the standard install location when unset, so
// the field is pre-populated if TQSL is present. // 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 { if a.settings == nil {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
mode := string(extsvc.ModeImmediate) // Preserve the chosen upload timing — including "on_close", which the LoTW
if cfg.QRZ.UploadMode == extsvc.ModeDelayed { // batch flush at shutdown depends on. (A previous version collapsed anything
mode = string(extsvc.ModeDelayed) // 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" auto := "0"
if cfg.QRZ.AutoUpload { if cfg.QRZ.AutoUpload {
auto = "1" auto = "1"
} }
clMode := string(extsvc.ModeImmediate) clMode := modeOf(cfg.Clublog.UploadMode)
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
clMode = string(extsvc.ModeDelayed)
}
clAuto := "0" clAuto := "0"
if cfg.Clublog.AutoUpload { if cfg.Clublog.AutoUpload {
clAuto = "1" clAuto = "1"
} }
ltMode := string(extsvc.ModeImmediate) ltMode := modeOf(cfg.LoTW.UploadMode)
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
ltMode = string(extsvc.ModeDelayed)
}
ltAuto := "0" ltAuto := "0"
if cfg.LoTW.AutoUpload { if cfg.LoTW.AutoUpload {
ltAuto = "1" ltAuto = "1"
} }
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag)) ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
if ltFlag != "N" && ltFlag != "R" {
ltFlag = "R"
}
ltWriteLog := "0" ltWriteLog := "0"
if cfg.LoTW.WriteLog { if cfg.LoTW.WriteLog {
ltWriteLog = "1" ltWriteLog = "1"
@@ -4946,7 +4977,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation), keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)), keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword, keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
keyExtLoTWUploadFlag: ltFlag, keyExtLoTWUploadFlags: ltFlags,
keyExtLoTWWriteLog: ltWriteLog, keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto, keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode, keyExtLoTWUploadMode: ltMode,
@@ -5682,6 +5713,70 @@ func (a *App) stationCallOf(id int64) string {
return strings.ToUpper(strings.TrimSpace(q.StationCallsign)) 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, // 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 // 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 // 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 return true
case extsvc.ServiceLoTW: case extsvc.ServiceLoTW:
flag := "R" for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if a.settings != nil { if strings.EqualFold(q.LOTWSent, f) {
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil { return true
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
flag = v
}
} }
} }
return strings.EqualFold(q.LOTWSent, flag) return false
} }
return false return false
} }
+26 -11
View File
@@ -749,14 +749,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
api_key: string; email: string; username: 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_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string; auto_upload: boolean; upload_mode: string;
}; };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', 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_flags: ['N', 'R'], write_log: false,
auto_upload: false, upload_mode: 'immediate', auto_upload: false, upload_mode: 'immediate',
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ const [extSvc, setExtSvc] = useState<ExternalServices>({
@@ -2892,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
placeholder="only if your certificate key has a password" placeholder="only if your certificate key has a password"
className="text-xs" className="text-xs"
/> />
<Label className="text-sm">Upload flag</Label> <Label className="text-sm">Consider as unsent</Label>
<div> <div>
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}> <div className="flex items-center gap-4">
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger> {(['N', 'R'] as const).map((f) => {
<SelectContent> const flags = lotw.upload_flags ?? [];
<SelectItem value="N">Upload when LoTW sent = No</SelectItem> const checked = flags.includes(f);
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem> return (
</SelectContent> <label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
</Select> <Checkbox
checked={checked}
onCheckedChange={(c) => {
const next = c
? Array.from(new Set([...flags, f]))
: flags.filter((x) => x !== f);
setLotw({ upload_flags: next });
}}
/>
{f === 'N' ? 'No (N)' : 'Requested (R)'}
</label>
);
})}
</div>
<div className="text-[10px] text-muted-foreground mt-1"> <div className="text-[10px] text-muted-foreground mt-1">
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up. At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -673,7 +673,7 @@ export namespace extsvc {
tqsl_path: string; tqsl_path: string;
station_location: string; station_location: string;
key_password: string; key_password: string;
upload_flag: string; upload_flags: string[];
write_log: boolean; write_log: boolean;
auto_upload: boolean; auto_upload: boolean;
upload_mode: string; upload_mode: string;
@@ -693,7 +693,7 @@ export namespace extsvc {
this.tqsl_path = source["tqsl_path"]; this.tqsl_path = source["tqsl_path"];
this.station_location = source["station_location"]; this.station_location = source["station_location"];
this.key_password = source["key_password"]; 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.write_log = source["write_log"];
this.auto_upload = source["auto_upload"]; this.auto_upload = source["auto_upload"];
this.upload_mode = source["upload_mode"]; this.upload_mode = source["upload_mode"];
+13 -7
View File
@@ -69,7 +69,7 @@ type ServiceConfig struct {
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional) 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 WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
AutoUpload bool `json:"auto_upload"` AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"` UploadMode UploadMode `json:"upload_mode"`
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign)) c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
c.TQSLPath = strings.TrimSpace(c.TQSLPath) c.TQSLPath = strings.TrimSpace(c.TQSLPath)
c.StationLocation = strings.TrimSpace(c.StationLocation) c.StationLocation = strings.TrimSpace(c.StationLocation)
// Upload flag is the LoTW sent-status that marks a QSO ready to upload. // Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; default to "R". // Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" { // here (the caller injects N+R when nothing is configured).
c.UploadFlag = uf var flags []string
} else { seen := map[string]bool{}
c.UploadFlag = "R" 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 { switch c.UploadMode {
case ModeDelayed, ModeOnClose: case ModeDelayed, ModeOnClose:
// keep // keep
+65 -38
View File
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
return baseCall(a) == baseCall(b) 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 // Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and // function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable. // keeps the upload-scheduling logic testable.
@@ -62,6 +67,13 @@ type Deps struct {
// option would otherwise silently relabel it). "" → no station call known. // option would otherwise silently relabel it). "" → no station call known.
StationCallOf func(id int64) string 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 is an optional diagnostic logger.
Logf func(format string, args ...any) Logf func(format string, args ...any)
} }
@@ -72,10 +84,9 @@ type Deps struct {
type Manager struct { type Manager struct {
deps Deps deps Deps
mu sync.Mutex mu sync.Mutex
cfg ExternalServices cfg ExternalServices
rnd *rand.Rand rnd *rand.Rand
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
} }
func NewManager(deps Deps) *Manager { func NewManager(deps Deps) *Manager {
@@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager {
deps: deps, deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable // Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random. // enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())), rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
pending: map[Service][]int64{},
} }
} }
@@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised() cfg.QRZ = cfg.QRZ.normalised()
cfg.Clublog = cfg.Clublog.normalised()
cfg.LoTW = cfg.LoTW.normalised()
m.cfg = cfg m.cfg = cfg
} }
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
// app-close batch, or schedule an immediate / delayed upload. // app-close batch, or schedule an immediate / delayed upload.
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) { func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeOnClose { if cfg.UploadMode == ModeOnClose {
m.mu.Lock() // Nothing to queue: on-close upload sweeps the whole logbook from the
m.pending[svc] = append(m.pending[svc], id) // database at shutdown (see FlushOnClose), so this QSO is picked up by
n := len(m.pending[svc]) // its sent-status then — no in-memory tracking needed.
m.mu.Unlock()
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
return return
} }
m.scheduleUpload(svc, id, cfg) 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) go m.upload(svc, id, cfg)
} }
// PendingCount returns how many QSOs are queued for on-close upload across // onCloseServices returns the services configured for on-close auto-upload,
// all services. The shutdown sequence uses it to decide whether to show the // with the minimum credentials to actually run.
// upload step. func (m *Manager) onCloseServices() []Service {
func (m *Manager) PendingCount() int { cfg := m.Config()
m.mu.Lock() var out []Service
defer m.mu.Unlock() 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 n := 0
for _, ids := range m.pending { for _, svc := range m.onCloseServices() {
n += len(ids) n += len(m.deps.CloseUploadIDs(svc))
} }
return n return n
} }
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence. // FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a // logbook (not just this session). Called from the shutdown sequence. QRZ/Club
// single TQSL batch. Returns the number of QSOs uploaded successfully. // 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 { func (m *Manager) FlushOnClose() int {
m.mu.Lock() if m.deps.CloseUploadIDs == nil {
pending := m.pending return 0
m.pending = map[Service][]int64{} }
cfg := m.cfg cfg := m.Config()
m.mu.Unlock()
uploaded := 0 uploaded := 0
for svc, ids := range pending { for _, svc := range m.onCloseServices() {
ids := m.deps.CloseUploadIDs(svc)
if len(ids) == 0 { if len(ids) == 0 {
continue continue
} }
switch svc { switch svc {
case ServiceLoTW: case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW) uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
default: case ServiceQRZ:
var sc ServiceConfig
switch svc {
case ServiceQRZ:
sc = cfg.QRZ
case ServiceClublog:
sc = cfg.Clublog
}
for _, id := range ids { 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++ uploaded++
} }
} }
+50
View File
@@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
return out, rows.Err() 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 // 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 // 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 // YYYYMMDD string. Only the two QRZ columns are touched — no full-row