fix: bug sending LoTW on close
This commit is contained in:
@@ -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"
|
||||||
@@ -729,6 +730,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
NotifyError: a.notifyExtError,
|
NotifyError: a.notifyExtError,
|
||||||
ShouldUpload: a.extShouldUpload,
|
ShouldUpload: a.extShouldUpload,
|
||||||
StationCallOf: a.stationCallOf,
|
StationCallOf: a.stationCallOf,
|
||||||
|
CloseUploadIDs: a.closeUploadIDs,
|
||||||
Logf: applog.Printf,
|
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 false
|
||||||
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;
|
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>
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+60
-33
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -75,7 +87,6 @@ type Manager struct {
|
|||||||
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 {
|
||||||
@@ -87,7 +98,6 @@ func NewManager(deps Deps) *Manager {
|
|||||||
// 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:
|
|
||||||
var sc ServiceConfig
|
|
||||||
switch svc {
|
|
||||||
case ServiceQRZ:
|
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++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user