feat: upload to external services clublog qrz
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||
// keeps the upload-scheduling logic testable.
|
||||
type Deps struct {
|
||||
Client *http.Client
|
||||
|
||||
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
|
||||
// overridden by forceCall when non-empty. ok=false means "skip silently"
|
||||
// (row gone, missing required fields, …).
|
||||
BuildADIF func(id int64, forceCall string) (record string, ok bool)
|
||||
|
||||
// MarkUploaded stamps the per-service upload status on the QSO row and
|
||||
// notifies the UI. Called once, on success.
|
||||
MarkUploaded func(svc Service, id int64, logID string)
|
||||
|
||||
// NotifyError surfaces a failed upload (logging + optional UI event).
|
||||
NotifyError func(svc Service, id int64, err error)
|
||||
|
||||
// Logf is an optional diagnostic logger.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Manager owns the external-service config snapshot and schedules uploads
|
||||
// when a QSO is logged. Immediate uploads run in their own goroutine;
|
||||
// delayed uploads use a timer with a random 1–2 minute fuse.
|
||||
type Manager struct {
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
if deps.Client == nil {
|
||||
deps.Client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &Manager{
|
||||
deps: deps,
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) logf(format string, args ...any) {
|
||||
if m.deps.Logf != nil {
|
||||
m.deps.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig replaces the active config snapshot (called after the user
|
||||
// saves the External Services settings).
|
||||
func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cfg.QRZ = cfg.QRZ.normalised()
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
// Config returns the current snapshot.
|
||||
func (m *Manager) Config() ExternalServices {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.cfg
|
||||
}
|
||||
|
||||
// delaySeconds returns a random 60–120s fuse for delayed uploads.
|
||||
func (m *Manager) delaySeconds() time.Duration {
|
||||
m.mu.Lock()
|
||||
d := 60 + m.rnd.Intn(61) // [60, 120]
|
||||
m.mu.Unlock()
|
||||
return time.Duration(d) * time.Second
|
||||
}
|
||||
|
||||
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
|
||||
// auto-log). It fans out to every enabled, auto-upload service in the
|
||||
// configured timing mode. Returns immediately.
|
||||
func (m *Manager) OnQSOLogged(id int64) {
|
||||
cfg := m.Config()
|
||||
|
||||
// QRZ.com
|
||||
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
||||
m.scheduleUpload(ServiceQRZ, id, qrz)
|
||||
}
|
||||
// Club Log — email + password + callsign are enough (no API key).
|
||||
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
||||
m.scheduleUpload(ServiceClublog, id, cl)
|
||||
}
|
||||
// LoTW will be added here.
|
||||
}
|
||||
|
||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeDelayed {
|
||||
d := m.delaySeconds()
|
||||
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
|
||||
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
|
||||
return
|
||||
}
|
||||
go m.upload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// upload performs the actual push. It builds a fresh, lifecycle-independent
|
||||
// context so a delayed upload still completes even if it fires close to
|
||||
// shutdown.
|
||||
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var res UploadResult
|
||||
var err error
|
||||
switch svc {
|
||||
case ServiceQRZ:
|
||||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
}
|
||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||
case ServiceClublog:
|
||||
// Club Log takes the logbook callsign as a separate param, so the
|
||||
// ADIF keeps the QSO's own station call (no override).
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
}
|
||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil || !res.OK {
|
||||
if err == nil {
|
||||
err = errFromResult(res)
|
||||
}
|
||||
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
||||
if m.deps.MarkUploaded != nil {
|
||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user