fix: bug sending LoTW on close
This commit is contained in:
@@ -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"
|
||||
@@ -729,6 +730,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<ExternalServices>({
|
||||
@@ -2892,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
placeholder="only if your certificate key has a password"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Upload flag</Label>
|
||||
<Label className="text-sm">Consider as unsent</Label>
|
||||
<div>
|
||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-4">
|
||||
{(['N', 'R'] as const).map((f) => {
|
||||
const flags = lotw.upload_flags ?? [];
|
||||
const checked = flags.includes(f);
|
||||
return (
|
||||
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<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">
|
||||
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>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
|
||||
+60
-33
@@ -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)
|
||||
}
|
||||
@@ -75,7 +87,6 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
@@ -87,7 +98,6 @@ func NewManager(deps Deps) *Manager {
|
||||
// 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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user