feat: upload qrz.com clublog and lotw manually
This commit is contained in:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user