feat: upload qrz.com clublog and lotw manually

This commit is contained in:
2026-05-29 00:16:59 +02:00
parent edda183c16
commit 33a7b6c4ac
12 changed files with 1113 additions and 41 deletions
+233 -1
View File
@@ -99,6 +99,14 @@ const (
keyExtClublogAPIKey = "extsvc.clublog.api_key"
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
)
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -442,6 +450,7 @@ func (a *App) startup(ctx context.Context) {
BuildADIF: a.buildUploadADIF,
MarkUploaded: a.markExtUploaded,
NotifyError: a.notifyExtError,
ShouldUpload: a.extShouldUpload,
Logf: applog.Printf,
})
a.extsvc.SetConfig(a.loadExternalServices())
@@ -511,6 +520,15 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
out = append(out, shutdownStep{ID: "backup", Label: "Backing up database", Status: "pending"})
}
}
if a.extsvc != nil {
if n := a.extsvc.PendingCount(); n > 0 {
out = append(out, shutdownStep{
ID: "extsvc-upload",
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
Status: "pending",
})
}
}
return out
}
@@ -533,6 +551,9 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
switch steps[i].ID {
case "backup":
err = a.runBackupForShutdown()
case "extsvc-upload":
n := a.extsvc.FlushOnClose()
steps[i].Detail = fmt.Sprintf("%d uploaded", n)
}
if err != nil {
steps[i].Status = "error"
@@ -718,6 +739,10 @@ func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
out.Lat = lat
out.Lon = lon
}
// 3 decimals is ~110 m — plenty for a station/grid coordinate, and keeps
// the UI fields tidy.
out.Lat = math.Round(out.Lat*1000) / 1000
out.Lon = math.Round(out.Lon*1000) / 1000
return out
}
@@ -1299,7 +1324,10 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
m, err := a.settings.GetMany(a.ctx,
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode)
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWKeyPassword,
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
keyExtLoTWAutoUpload, keyExtLoTWUploadMode)
if err != nil {
return out
}
@@ -1324,6 +1352,20 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
out.Clublog.Callsign = p.Callsign
}
}
out.LoTW = extsvc.ServiceConfig{
TQSLPath: m[keyExtLoTWTQSLPath],
StationLocation: m[keyExtLoTWStationLoc],
KeyPassword: m[keyExtLoTWKeyPassword],
UploadFlag: m[keyExtLoTWUploadFlag],
WriteLog: m[keyExtLoTWWriteLog] == "1",
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
}
// Default the TQSL path to the standard install location when unset, so
// the field is pre-populated if TQSL is present.
if out.LoTW.TQSLPath == "" {
out.LoTW.TQSLPath = extsvc.DefaultTQSLPath()
}
return out
}
@@ -1354,6 +1396,22 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if cfg.Clublog.AutoUpload {
clAuto = "1"
}
ltMode := string(extsvc.ModeImmediate)
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
ltMode = string(extsvc.ModeDelayed)
}
ltAuto := "0"
if cfg.LoTW.AutoUpload {
ltAuto = "1"
}
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
if ltFlag != "N" && ltFlag != "R" {
ltFlag = "R"
}
ltWriteLog := "0"
if cfg.LoTW.WriteLog {
ltWriteLog = "1"
}
for k, v := range map[string]string{
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
@@ -1366,6 +1424,14 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
keyExtClublogAutoUpload: clAuto,
keyExtClublogUploadMode: clMode,
keyExtLoTWTQSLPath: strings.TrimSpace(cfg.LoTW.TQSLPath),
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
keyExtLoTWUploadFlag: ltFlag,
keyExtLoTWWriteLog: ltWriteLog,
keyExtLoTWAutoUpload: ltAuto,
keyExtLoTWUploadMode: ltMode,
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -1389,6 +1455,135 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// ── QSL Manager (manual upload) ────────────────────────────────────────
// uploadColumnFor maps a service id to its QSO sent-status column.
func uploadColumnFor(service string) string {
switch extsvc.Service(service) {
case extsvc.ServiceQRZ:
return "qrzcom_qso_upload_status"
case extsvc.ServiceClublog:
return "clublog_qso_upload_status"
case extsvc.ServiceLoTW:
return "lotw_sent"
}
return ""
}
// FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
col := uploadColumnFor(service)
if col == "" {
return nil, fmt.Errorf("unknown service %q", service)
}
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
}
// UploadQSOsManual uploads the given QSO ids to a service on demand
// (regardless of their current sent status — the user picked them). Runs in
// the background, emitting "qslmgr:log" lines and a final "qslmgr:done".
func (a *App) UploadQSOsManual(service string, ids []int64) error {
if a.qso == nil {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
if uploadColumnFor(service) == "" {
return fmt.Errorf("unknown service %q", service)
}
cfg := a.loadExternalServices()
go a.runManualUpload(svc, ids, cfg)
return nil
}
func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.ExternalServices) {
emit := func(line string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
}
}
ctx := context.Background()
uploaded := 0
if svc == extsvc.ServiceLoTW {
emit(fmt.Sprintf("Signing %d QSO(s) with TQSL…", len(ids)))
var recs []string
for _, id := range ids {
if rec, ok := a.buildUploadADIF(id, ""); ok {
recs = append(recs, rec)
}
}
res, err := extsvc.UploadLoTW(ctx, cfg.LoTW, "", strings.Join(recs, "\n"))
if err != nil || !res.OK {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit("LoTW upload failed: " + msg)
} else {
for _, id := range ids {
a.markExtUploaded(svc, id, "")
uploaded++
}
emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded))
}
} else {
for _, id := range ids {
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
force := ""
if svc == extsvc.ServiceQRZ {
force = cfg.QRZ.ForceStationCallsign
}
rec, ok := a.buildUploadADIF(id, force)
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
switch svc {
case extsvc.ServiceQRZ:
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceClublog:
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, "")
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": uploaded, "total": len(ids)})
}
}
// ListTQSLStationLocations returns the Station Locations defined in TQSL,
// for the LoTW settings dropdown.
func (a *App) ListTQSLStationLocations() ([]extsvc.StationLocation, error) {
return extsvc.ListStationLocations(extsvc.DefaultStationDataPath())
}
// TestLoTWUpload validates the LoTW config (TQSL present + station location
// exists).
func (a *App) TestLoTWUpload() (string, error) {
return extsvc.TestLoTW(a.loadExternalServices().LoTW, extsvc.DefaultStationDataPath())
}
// buildUploadADIF builds a single-record ADIF for QSO id, overriding the
// station callsign when forceCall is set (QRZ rejects QSOs whose station
// call differs from the logbook's registered call). ok=false → skip.
@@ -1406,6 +1601,37 @@ func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
return adif.SingleRecordADIF(q), true
}
// 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
// ("N" or "R") — the Log4OM rule that must match the Confirmations default.
func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
if a.qso == nil {
return false
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
return false
}
switch svc {
case extsvc.ServiceQRZ:
return !strings.EqualFold(q.QRZComUploadStatus, "Y")
case extsvc.ServiceClublog:
return !strings.EqualFold(q.ClublogUploadStatus, "Y")
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
}
}
}
return strings.EqualFold(q.LOTWSent, flag)
}
return false
}
// markExtUploaded stamps the per-service upload status on the QSO row and
// tells the frontend to refresh that row's confirmation columns.
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
@@ -1423,6 +1649,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
}
}
case extsvc.ServiceLoTW:
if a.qso != nil {
if err := a.qso.MarkLoTWUploaded(a.ctx, id, date); err != nil {
applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err)
}
}
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{