From cdd71b17c85c6b8ae15f8e8a48edf1ff62c4ec15 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 18 Jun 2026 14:27:33 +0200 Subject: [PATCH] feat: implemented HRDLog upload --- app.go | 53 +++++++- app_secret.go | 1 + frontend/src/components/QSLManagerModal.tsx | 1 + frontend/src/components/SettingsModal.tsx | 90 ++++++++++++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 4 + internal/extsvc/extsvc.go | 6 +- internal/extsvc/hrdlog.go | 141 ++++++++++++++++++++ internal/extsvc/manager.go | 25 +++- internal/qso/qso.go | 14 ++ 11 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 internal/extsvc/hrdlog.go diff --git a/app.go b/app.go index 755f474..a263888 100644 --- a/app.go +++ b/app.go @@ -190,6 +190,11 @@ const ( keyExtClublogAutoUpload = "extsvc.clublog.auto_upload" keyExtClublogUploadMode = "extsvc.clublog.upload_mode" + keyExtHRDLogCallsign = "extsvc.hrdlog.callsign" + keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code + keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload" + keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode" + keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" @@ -4867,7 +4872,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword, keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog, keyExtLoTWAutoUpload, keyExtLoTWUploadMode, - keyExtLoTWUsername, keyExtLoTWWebPassword) + keyExtLoTWUsername, keyExtLoTWWebPassword, + keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode) if err != nil { return out } @@ -4912,6 +4918,18 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { if out.LoTW.TQSLPath == "" { out.LoTW.TQSLPath = extsvc.DefaultTQSLPath() } + out.HRDLog = extsvc.ServiceConfig{ + Callsign: m[keyExtHRDLogCallsign], + Code: m[keyExtHRDLogCode], + AutoUpload: m[keyExtHRDLogAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]), + } + // Default the HRDLog callsign to the active profile's call when unset. + if out.HRDLog.Callsign == "" && a.profiles != nil { + if p, perr := a.profiles.Active(a.ctx); perr == nil { + out.HRDLog.Callsign = p.Callsign + } + } return out } @@ -4959,6 +4977,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if cfg.LoTW.WriteLog { ltWriteLog = "1" } + hlMode := modeOf(cfg.HRDLog.UploadMode) + hlAuto := "0" + if cfg.HRDLog.AutoUpload { + hlAuto = "1" + } scope := a.profileScope() // write under the active profile's prefix for k, v := range map[string]string{ keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), @@ -4983,6 +5006,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtLoTWUploadMode: ltMode, keyExtLoTWUsername: strings.TrimSpace(cfg.LoTW.Username), keyExtLoTWWebPassword: cfg.LoTW.Password, + + keyExtHRDLogCallsign: strings.ToUpper(strings.TrimSpace(cfg.HRDLog.Callsign)), + keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code), + keyExtHRDLogAutoUpload: hlAuto, + keyExtHRDLogUploadMode: hlMode, } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err @@ -5011,6 +5039,11 @@ func (a *App) TestClublogUpload() (string, error) { return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog) } +// TestHRDLogUpload validates that the HRDLog credentials are complete. +func (a *App) TestHRDLogUpload() (string, error) { + return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) +} + // ── QSL Manager (manual upload) ──────────────────────────────────────── // uploadColumnFor maps a service id to its QSO sent-status column. @@ -5022,6 +5055,8 @@ func uploadColumnFor(service string) string { return "clublog_qso_upload_status" case extsvc.ServiceLoTW: return "lotw_sent" + case extsvc.ServiceHRDLog: + return "hrdlog_qso_upload_status" } return "" } @@ -5160,6 +5195,8 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern switch svc { case extsvc.ServiceQRZ: res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) + case extsvc.ServiceHRDLog: + res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec) default: res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) } @@ -5731,6 +5768,8 @@ func (a *App) uploadOwnerCall(svc extsvc.Service) string { owner = cfg.QRZ.ForceStationCallsign case extsvc.ServiceClublog: owner = cfg.Clublog.Callsign + case extsvc.ServiceHRDLog: + owner = cfg.HRDLog.Callsign } owner = strings.ToUpper(strings.TrimSpace(owner)) if owner == "" && a.profiles != nil { @@ -5821,6 +5860,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { return false } return true + case extsvc.ServiceHRDLog: + if strings.EqualFold(q.HRDLogUploadStatus, "Y") { + applog.Printf("extsvc: QSO %d not eligible for hrdlog — HRDLogUploadStatus already %q (set Confirmations default to N to upload)", id, q.HRDLogUploadStatus) + return false + } + return true case extsvc.ServiceLoTW: for _, f := range a.loadExternalServices().LoTW.UploadFlags { if strings.EqualFold(q.LOTWSent, f) { @@ -5855,6 +5900,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { applog.Printf("extsvc: mark lotw uploaded %d: %v", id, err) } } + case extsvc.ServiceHRDLog: + if a.qso != nil { + if err := a.qso.MarkHRDLogUploaded(a.ctx, id, date); err != nil { + applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err) + } + } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ diff --git a/app_secret.go b/app_secret.go index 1e61ab1..d66ef79 100644 --- a/app_secret.go +++ b/app_secret.go @@ -24,6 +24,7 @@ var sensitiveSettingKeys = map[string]bool{ keyExtClublogPassword: true, keyExtLoTWKeyPassword: true, keyExtLoTWWebPassword: true, + keyExtHRDLogCode: true, } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 03e928e..77c8296 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -23,6 +23,7 @@ type Confirmation = { const SERVICES = [ { v: 'qrz', label: 'QRZ.com' }, { v: 'clublog', label: 'Club Log' }, + { v: 'hrdlog', label: 'HRDLog.net' }, { v: 'lotw', label: 'LoTW' }, { v: 'pota', label: 'POTA hunter log' }, { v: 'paper', label: 'Paper QSL' }, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e593552..2827522 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -31,7 +31,7 @@ import { GetTelemetryEnabled, SetTelemetryEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled, GetQSLDefaults, SaveQSLDefaults, - GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, + GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, @@ -747,20 +747,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // wired today. upload_mode is 'immediate' | 'delayed' (per-service). type ExtServiceCfg = { api_key: string; email: string; username: string; password: string; callsign: string; + code: string; force_station_callsign: string; tqsl_path: string; station_location: string; key_password: string; upload_flags: string[]; write_log: boolean; auto_upload: boolean; upload_mode: string; }; - type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ - api_key: '', email: '', username: '', password: '', callsign: '', + api_key: '', email: '', username: '', password: '', callsign: '', code: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', upload_flags: ['N', 'R'], write_log: false, auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ - qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), + qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), }); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTesting, setQrzTesting] = useState(false); @@ -768,6 +769,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [clublogTesting, setClublogTesting] = useState(false); const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null); const [lotwTesting, setLotwTesting] = useState(false); + const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [hrdlogTesting, setHrdlogTesting] = useState(false); const [stationLocations, setStationLocations] = useState([]); // Active tab in the External Services panel — lifted here because // PANELS[selected]() is called as a function, so panels can't hold hooks. @@ -2603,7 +2606,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [ { k: 'qrz', label: 'QRZ.COM', ready: true }, { k: 'clublog', label: 'CLUBLOG', ready: true }, - { k: 'hrdlog', label: 'HRDLOG.NET' }, + { k: 'hrdlog', label: 'HRDLOG.NET', ready: true }, { k: 'eqsl', label: 'EQSL' }, { k: 'hamqth', label: 'HAMQTH' }, { k: 'lotw', label: 'LOTW', ready: true }, @@ -2658,6 +2661,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } } + const hrdlog = extSvc.hrdlog; + const setHrdlog = (patch: Partial) => + setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } })); + + async function testHrdlog() { + setHrdlogTesting(true); + setHrdlogTest(null); + try { + await SaveExternalServices(extSvc as any); + const msg = await TestHRDLogUpload(); + setHrdlogTest({ ok: true, msg }); + } catch (e: any) { + setHrdlogTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setHrdlogTesting(false); + } + } + const lotw = extSvc.lotw; const setLotw = (patch: Partial) => setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } })); @@ -2833,6 +2854,65 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + ) : extSvcTab === 'hrdlog' ? ( +
+
+ + setHrdlog({ callsign: e.target.value.toUpperCase() })} + placeholder="defaults to the active profile's callsign" + className="font-mono text-xs" + /> + + setHrdlog({ code: e.target.value })} + placeholder="HRDLog account → My Account → Upload code" + className="text-xs" + /> +
+
+ Find the upload code on HRDLog.net under My Account. It authorises uploads for this callsign. +
+ +
+ + +
+ + +
+ +
+ + {hrdlogTest && ( + + {hrdlogTest.msg} + + )} +
+
+
) : extSvcTab === 'lotw' ? (
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index a2d4b9b..f4f325e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -473,6 +473,8 @@ export function TestClublogUpload():Promise; export function TestEmail(arg1:string):Promise; +export function TestHRDLogUpload():Promise; + export function TestLoTWUpload():Promise; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 5d7ce88..23440cc 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -918,6 +918,10 @@ export function TestEmail(arg1) { return window['go']['main']['App']['TestEmail'](arg1); } +export function TestHRDLogUpload() { + return window['go']['main']['App']['TestHRDLogUpload'](); +} + export function TestLoTWUpload() { return window['go']['main']['App']['TestLoTWUpload'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3f62c59..f2607c3 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -669,6 +669,7 @@ export namespace extsvc { username: string; password: string; callsign: string; + code: string; force_station_callsign: string; tqsl_path: string; station_location: string; @@ -689,6 +690,7 @@ export namespace extsvc { this.username = source["username"]; this.password = source["password"]; this.callsign = source["callsign"]; + this.code = source["code"]; this.force_station_callsign = source["force_station_callsign"]; this.tqsl_path = source["tqsl_path"]; this.station_location = source["station_location"]; @@ -703,6 +705,7 @@ export namespace extsvc { qrz: ServiceConfig; clublog: ServiceConfig; lotw: ServiceConfig; + hrdlog: ServiceConfig; static createFrom(source: any = {}) { return new ExternalServices(source); @@ -713,6 +716,7 @@ export namespace extsvc { this.qrz = this.convertValues(source["qrz"], ServiceConfig); this.clublog = this.convertValues(source["clublog"], ServiceConfig); this.lotw = this.convertValues(source["lotw"], ServiceConfig); + this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 5ed048a..7851436 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -31,6 +31,7 @@ const ( ServiceQRZ Service = "qrz" // QRZ.com Logbook ServiceClublog Service = "clublog" // Club Log real-time upload ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL) + ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload ) // UploadMode selects when an auto-upload fires after a QSO is saved. @@ -64,7 +65,8 @@ type ServiceConfig struct { Email string `json:"email"` // Club Log account email Username string `json:"username"` // LoTW website login (for confirmation download) Password string `json:"password"` // Club Log account / LoTW website password - Callsign string `json:"callsign"` // Club Log logbook (owner) callsign + Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign + Code string `json:"code"` // HRDLog: account upload code ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name @@ -81,6 +83,7 @@ func (c ServiceConfig) normalised() ServiceConfig { c.APIKey = strings.TrimSpace(c.APIKey) c.Email = strings.TrimSpace(c.Email) c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign)) + c.Code = strings.TrimSpace(c.Code) c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign)) c.TQSLPath = strings.TrimSpace(c.TQSLPath) c.StationLocation = strings.TrimSpace(c.StationLocation) @@ -111,6 +114,7 @@ type ExternalServices struct { QRZ ServiceConfig `json:"qrz"` Clublog ServiceConfig `json:"clublog"` LoTW ServiceConfig `json:"lotw"` + HRDLog ServiceConfig `json:"hrdlog"` } // UploadResult is the outcome of a single upload attempt. diff --git a/internal/extsvc/hrdlog.go b/internal/extsvc/hrdlog.go new file mode 100644 index 0000000..85e0ee0 --- /dev/null +++ b/internal/extsvc/hrdlog.go @@ -0,0 +1,141 @@ +package extsvc + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a +// form-encoded POST with the uploader's callsign, the account's secret upload +// Code (HRDLog account → "My Account" → upload code), an App identifier, and +// one ADIF record. +const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx" + +// hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to +// OpsLog in the user's HRDLog activity. +const hrdlogApp = "OpsLog" + +// hrdlogPost performs the form POST to NewEntry.aspx and returns the raw +// response body. The endpoint replies HTTP 200 with a small XML document even +// for errors; callers classify it via the markers in classifyHRDLog. +func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) { + form := url.Values{} + form.Set("Callsign", callsign) + form.Set("Code", code) + form.Set("App", hrdlogApp) + form.Set("ADIFData", adif) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("hrdlog: build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("hrdlog: request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + msg := strings.TrimSpace(string(body)) + if resp.StatusCode != http.StatusOK { + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg) + } + return msg, nil +} + +// authErrHRDLog returns a non-empty, human-readable reason when the response +// signals a credential problem (wrong upload code or unregistered callsign), +// or "" otherwise. Markers mirror HRDLog's documented XML replies +// ("Invalid token" / "Unknown user"). +func authErrHRDLog(body string) string { + b := strings.ToLower(body) + switch { + case strings.Contains(b, "invalid token"): + return "invalid upload code" + case strings.Contains(b, "unknown user"): + return "callsign not registered at HRDLog" + default: + return "" + } +} + +// UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station +// callsign the log belongs to, code is the account's upload code. +// +// Form fields (application/x-www-form-urlencoded POST): +// +// Callsign=&Code=&App=OpsLog&ADIFData= +// +// HRDLog replies with XML: "1" on success, "0" for a duplicate +// (already logged — treated as success so retries are idempotent), or an +// "" payload otherwise. +func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) { + callsign = strings.ToUpper(strings.TrimSpace(callsign)) + code = strings.TrimSpace(code) + if callsign == "" { + return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set") + } + if code == "" { + return UploadResult{}, fmt.Errorf("hrdlog: upload code not set") + } + if strings.TrimSpace(adifRecord) == "" { + return UploadResult{}, fmt.Errorf("hrdlog: empty adif record") + } + + body, err := hrdlogPost(ctx, client, callsign, code, adifRecord) + if err != nil { + return UploadResult{OK: false, Message: body}, err + } + + b := strings.ToLower(body) + switch { + case strings.Contains(b, "1"): + return UploadResult{OK: true, Message: "uploaded"}, nil + case strings.Contains(b, "0"): + return UploadResult{OK: true, Message: "already in logbook"}, nil + } + reason := authErrHRDLog(body) + if reason == "" { + reason = body + if reason == "" { + reason = "upload rejected" + } + } + return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason) +} + +// TestHRDLog validates the configured HRDLog credentials with a REAL request: +// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth +// errors. A wrong upload code comes back as "Invalid token", a wrong callsign +// as "Unknown user"; anything else means the credentials were accepted (HRDLog +// simply had no QSO to add). +func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { + callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign)) + code := strings.TrimSpace(cfg.Code) + if callsign == "" { + return "", fmt.Errorf("hrdlog: station callsign not set") + } + if code == "" { + return "", fmt.Errorf("hrdlog: upload code not set") + } + body, err := hrdlogPost(ctx, client, callsign, code, "") + if err != nil { + return "", err + } + if reason := authErrHRDLog(body); reason != "" { + return "", fmt.Errorf("hrdlog: %s", reason) + } + return fmt.Sprintf("Credentials accepted — %s", callsign), nil +} diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index 07236e7..215fdb7 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -115,6 +115,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { cfg.QRZ = cfg.QRZ.normalised() cfg.Clublog = cfg.Clublog.normalised() cfg.LoTW = cfg.LoTW.normalised() + cfg.HRDLog = cfg.HRDLog.normalised() m.cfg = cfg } @@ -151,6 +152,10 @@ func (m *Manager) OnQSOLogged(id int64) { if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" { m.route(ServiceLoTW, id, lt) } + // HRDLog — needs the station callsign + the account upload code. + if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" { + m.route(ServiceHRDLog, id, h) + } } // route sends a logged QSO down the configured timing path: queue it for the @@ -190,6 +195,9 @@ func (m *Manager) onCloseServices() []Service { if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" { out = append(out, ServiceLoTW) } + if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" { + out = append(out, ServiceHRDLog) + } return out } @@ -237,6 +245,12 @@ func (m *Manager) FlushOnClose() int { uploaded++ } } + case ServiceHRDLog: + for _, id := range ids { + if m.upload(svc, id, cfg.HRDLog) { + uploaded++ + } + } } } return uploaded @@ -303,7 +317,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool { switch svc { case ServiceQRZ, ServiceLoTW: owner = cfg.ForceStationCallsign - case ServiceClublog: + case ServiceClublog, ServiceHRDLog: owner = cfg.Callsign } if owner != "" && m.deps.StationCallOf != nil { @@ -351,6 +365,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool { return false } res, err = UploadLoTW(ctx, cfg, "", record) + case ServiceHRDLog: + // HRDLog takes the station callsign as a separate param, so the ADIF + // keeps the QSO's own station call (no override), like Club Log. + record, ok := m.deps.BuildADIF(id, "") + if !ok { + m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) + return false + } + res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record) default: return false } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index fae158a..bf8b427 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -491,6 +491,7 @@ var uploadStatusCols = map[string]bool{ "lotw_sent": true, "qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, + "hrdlog_qso_upload_status": true, } // ListForUpload returns QSOs whose per-service sent-status column equals @@ -597,6 +598,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e return nil } +// MarkHRDLogUploaded stamps HRDLOG_QSO_UPLOAD_STATUS=Y and the upload date +// after a successful HRDLog.net push. date is an ADIF YYYYMMDD string. +func (r *Repo) MarkHRDLogUploaded(ctx context.Context, id int64, date string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET hrdlog_qso_upload_status = 'Y', hrdlog_qso_upload_date = ?, + updated_at = ? WHERE id = ?`, + date, db.NowISO(), id) + if err != nil { + return fmt.Errorf("mark hrdlog uploaded %d: %w", id, err) + } + return nil +} + // MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a // successful TQSL upload. date is an ADIF YYYYMMDD string. func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {