feat: upload to external services clublog qrz
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/extsvc"
|
||||
"hamlog/internal/integrations/udp"
|
||||
"hamlog/internal/operating"
|
||||
"hamlog/internal/dxcc"
|
||||
@@ -83,6 +84,21 @@ const (
|
||||
keyQSLDefaultEQSLRcvd = "qsl.eqsl_rcvd"
|
||||
keyQSLDefaultClublogStatus = "qsl.clublog_status"
|
||||
keyQSLDefaultHRDLogStatus = "qsl.hrdlog_status"
|
||||
keyQSLDefaultQRZComStatus = "qsl.qrzcom_status"
|
||||
|
||||
// External services (logbook upload). QRZ.com first; Clublog / LoTW
|
||||
// will add their own keys under the same extsvc.* prefix.
|
||||
keyExtQRZAPIKey = "extsvc.qrz.api_key"
|
||||
keyExtQRZForceCall = "extsvc.qrz.force_station_callsign"
|
||||
keyExtQRZAutoUpload = "extsvc.qrz.auto_upload"
|
||||
keyExtQRZUploadMode = "extsvc.qrz.upload_mode"
|
||||
|
||||
keyExtClublogEmail = "extsvc.clublog.email"
|
||||
keyExtClublogPassword = "extsvc.clublog.password"
|
||||
keyExtClublogCallsign = "extsvc.clublog.callsign"
|
||||
keyExtClublogAPIKey = "extsvc.clublog.api_key"
|
||||
keyExtClublogAutoUpload = "extsvc.clublog.auto_upload"
|
||||
keyExtClublogUploadMode = "extsvc.clublog.upload_mode"
|
||||
)
|
||||
|
||||
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
|
||||
@@ -99,6 +115,7 @@ type QSLDefaults struct {
|
||||
EQSLRcvd string `json:"eqsl_rcvd"`
|
||||
ClublogStatus string `json:"clublog_status"`
|
||||
HRDLogStatus string `json:"hrdlog_status"`
|
||||
QRZComStatus string `json:"qrzcom_status"`
|
||||
}
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
@@ -184,6 +201,7 @@ type App struct {
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
|
||||
@@ -417,6 +435,17 @@ func (a *App) startup(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// External-service uploaders (QRZ.com …). The manager is fed config
|
||||
// from settings and host callbacks to build ADIF, stamp the upload
|
||||
// status and surface errors to the UI.
|
||||
a.extsvc = extsvc.NewManager(extsvc.Deps{
|
||||
BuildADIF: a.buildUploadADIF,
|
||||
MarkUploaded: a.markExtUploaded,
|
||||
NotifyError: a.notifyExtError,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
@@ -648,7 +677,11 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyQSLDefaults(&q)
|
||||
return a.qso.Add(a.ctx, q)
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err == nil && a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// StationInfoComputed bundles the data we resolve live from the
|
||||
@@ -1171,6 +1204,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
keyQSLDefaultLOTWSent, keyQSLDefaultLOTWRcvd,
|
||||
keyQSLDefaultEQSLSent, keyQSLDefaultEQSLRcvd,
|
||||
keyQSLDefaultClublogStatus, keyQSLDefaultHRDLogStatus,
|
||||
keyQSLDefaultQRZComStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -1183,6 +1217,7 @@ func (a *App) GetQSLDefaults() (QSLDefaults, error) {
|
||||
out.EQSLRcvd = m[keyQSLDefaultEQSLRcvd]
|
||||
out.ClublogStatus = m[keyQSLDefaultClublogStatus]
|
||||
out.HRDLogStatus = m[keyQSLDefaultHRDLogStatus]
|
||||
out.QRZComStatus = m[keyQSLDefaultQRZComStatus]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1201,6 +1236,7 @@ func (a *App) SaveQSLDefaults(d QSLDefaults) error {
|
||||
keyQSLDefaultEQSLRcvd: strings.ToUpper(strings.TrimSpace(d.EQSLRcvd)),
|
||||
keyQSLDefaultClublogStatus: strings.ToUpper(strings.TrimSpace(d.ClublogStatus)),
|
||||
keyQSLDefaultHRDLogStatus: strings.ToUpper(strings.TrimSpace(d.HRDLogStatus)),
|
||||
keyQSLDefaultQRZComStatus: strings.ToUpper(strings.TrimSpace(d.QRZComStatus)),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -1229,6 +1265,163 @@ func (a *App) applyQSLDefaults(q *qso.QSO) {
|
||||
if q.EQSLRcvd == "" { q.EQSLRcvd = d.EQSLRcvd }
|
||||
if q.ClublogUploadStatus == "" { q.ClublogUploadStatus = d.ClublogStatus }
|
||||
if q.HRDLogUploadStatus == "" { q.HRDLogUploadStatus = d.HRDLogStatus }
|
||||
if q.QRZComUploadStatus == "" { q.QRZComUploadStatus = d.QRZComStatus }
|
||||
}
|
||||
|
||||
// ── External services (logbook upload) ─────────────────────────────────
|
||||
|
||||
// loadExternalServices reads the configured external-service settings.
|
||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
var out extsvc.ExternalServices
|
||||
if a.settings == nil {
|
||||
return out
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyExtQRZAPIKey, keyExtQRZForceCall, keyExtQRZAutoUpload, keyExtQRZUploadMode,
|
||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
out.QRZ = extsvc.ServiceConfig{
|
||||
APIKey: m[keyExtQRZAPIKey],
|
||||
ForceStationCallsign: m[keyExtQRZForceCall],
|
||||
AutoUpload: m[keyExtQRZAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtQRZUploadMode]),
|
||||
}
|
||||
out.Clublog = extsvc.ServiceConfig{
|
||||
Email: m[keyExtClublogEmail],
|
||||
Password: m[keyExtClublogPassword],
|
||||
Callsign: m[keyExtClublogCallsign],
|
||||
APIKey: m[keyExtClublogAPIKey],
|
||||
AutoUpload: m[keyExtClublogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtClublogUploadMode]),
|
||||
}
|
||||
// Default the Club Log logbook callsign to the active profile's call
|
||||
// when the user hasn't overridden it.
|
||||
if out.Clublog.Callsign == "" && a.profiles != nil {
|
||||
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||
out.Clublog.Callsign = p.Callsign
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetExternalServices returns the saved external-service configuration.
|
||||
func (a *App) GetExternalServices() (extsvc.ExternalServices, error) {
|
||||
return a.loadExternalServices(), nil
|
||||
}
|
||||
|
||||
// SaveExternalServices persists the config and reloads the live manager so
|
||||
// the next logged QSO uses the new settings (no restart needed).
|
||||
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)
|
||||
}
|
||||
auto := "0"
|
||||
if cfg.QRZ.AutoUpload {
|
||||
auto = "1"
|
||||
}
|
||||
clMode := string(extsvc.ModeImmediate)
|
||||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
||||
clMode = string(extsvc.ModeDelayed)
|
||||
}
|
||||
clAuto := "0"
|
||||
if cfg.Clublog.AutoUpload {
|
||||
clAuto = "1"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||
keyExtQRZForceCall: strings.ToUpper(strings.TrimSpace(cfg.QRZ.ForceStationCallsign)),
|
||||
keyExtQRZAutoUpload: auto,
|
||||
keyExtQRZUploadMode: mode,
|
||||
|
||||
keyExtClublogEmail: strings.TrimSpace(cfg.Clublog.Email),
|
||||
keyExtClublogPassword: cfg.Clublog.Password,
|
||||
keyExtClublogCallsign: strings.ToUpper(strings.TrimSpace(cfg.Clublog.Callsign)),
|
||||
keyExtClublogAPIKey: strings.TrimSpace(cfg.Clublog.APIKey),
|
||||
keyExtClublogAutoUpload: clAuto,
|
||||
keyExtClublogUploadMode: clMode,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestQRZUpload validates the configured QRZ key by querying the logbook's
|
||||
// status (ACTION=STATUS). Returns a human-readable message for the UI.
|
||||
func (a *App) TestQRZUpload() (string, error) {
|
||||
cfg := a.loadExternalServices().QRZ
|
||||
return extsvc.TestQRZ(a.ctx, nil, cfg.APIKey)
|
||||
}
|
||||
|
||||
// TestClublogUpload validates that the Club Log credentials are complete.
|
||||
func (a *App) TestClublogUpload() (string, error) {
|
||||
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *App) buildUploadADIF(id int64, forceCall string) (string, bool) {
|
||||
if a.qso == nil {
|
||||
return "", false
|
||||
}
|
||||
q, err := a.qso.GetByID(a.ctx, id)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if forceCall != "" {
|
||||
q.StationCallsign = forceCall
|
||||
}
|
||||
return adif.SingleRecordADIF(q), true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
date := time.Now().UTC().Format("20060102")
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkQRZUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark qrz uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
case extsvc.ServiceClublog:
|
||||
if a.qso != nil {
|
||||
if err := a.qso.MarkClublogUploaded(a.ctx, id, date); err != nil {
|
||||
applog.Printf("extsvc: mark clublog uploaded %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{
|
||||
"service": string(svc),
|
||||
"qso_id": id,
|
||||
"log_id": logID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notifyExtError surfaces a failed upload to the frontend.
|
||||
func (a *App) notifyExtError(svc extsvc.Service, id int64, err error) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "extsvc:error", map[string]any{
|
||||
"service": string(svc),
|
||||
"qso_id": id,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── UDP integrations ───────────────────────────────────────────────────
|
||||
@@ -1286,7 +1479,8 @@ func (a *App) ReloadUDPIntegrations() []string {
|
||||
|
||||
// LogUDPLoggedADIF takes an ADIF blob received over UDP and inserts the
|
||||
// first record into the local logbook. Returns the ID of the inserted
|
||||
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert).
|
||||
// row. Used by the auto-log handler (WSJT-X / JTDX / MSHV / JTAlert /
|
||||
// N1MM — the latter via a synthesised ADIF record from its XML datagram).
|
||||
func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
@@ -1379,6 +1573,9 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user