290 lines
8.4 KiB
Go
290 lines
8.4 KiB
Go
package extsvc
|
||
|
||
import (
|
||
"context"
|
||
"math/rand"
|
||
"net/http"
|
||
"strings"
|
||
"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)
|
||
|
||
// ShouldUpload reports whether a QSO is eligible for upload to this
|
||
// 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"), à la Log4OM. Returning false skips the QSO.
|
||
ShouldUpload func(svc Service, id int64) bool
|
||
|
||
// 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
|
||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
||
}
|
||
|
||
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())),
|
||
pending: map[Service][]int64{},
|
||
}
|
||
}
|
||
|
||
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.route(ServiceQRZ, id, qrz)
|
||
}
|
||
// Club Log — email + password + callsign are enough (no API key).
|
||
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
|
||
m.route(ServiceClublog, id, cl)
|
||
}
|
||
// LoTW — needs TQSL + a station location.
|
||
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
||
m.route(ServiceLoTW, id, lt)
|
||
}
|
||
}
|
||
|
||
// route sends a logged QSO down the configured timing path: queue it for the
|
||
// app-close batch, or schedule an immediate / delayed upload.
|
||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||
if cfg.UploadMode == ModeOnClose {
|
||
m.mu.Lock()
|
||
m.pending[svc] = append(m.pending[svc], id)
|
||
n := len(m.pending[svc])
|
||
m.mu.Unlock()
|
||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
||
return
|
||
}
|
||
m.scheduleUpload(svc, id, cfg)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// PendingCount returns how many QSOs are queued for on-close upload across
|
||
// all services. The shutdown sequence uses it to decide whether to show the
|
||
// upload step.
|
||
func (m *Manager) PendingCount() int {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
n := 0
|
||
for _, ids := range m.pending {
|
||
n += len(ids)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
||
func (m *Manager) FlushOnClose() int {
|
||
m.mu.Lock()
|
||
pending := m.pending
|
||
m.pending = map[Service][]int64{}
|
||
cfg := m.cfg
|
||
m.mu.Unlock()
|
||
|
||
uploaded := 0
|
||
for svc, ids := range pending {
|
||
if len(ids) == 0 {
|
||
continue
|
||
}
|
||
switch svc {
|
||
case ServiceLoTW:
|
||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||
default:
|
||
var sc ServiceConfig
|
||
switch svc {
|
||
case ServiceQRZ:
|
||
sc = cfg.QRZ
|
||
case ServiceClublog:
|
||
sc = cfg.Clublog
|
||
}
|
||
for _, id := range ids {
|
||
if m.upload(svc, id, sc) {
|
||
uploaded++
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return uploaded
|
||
}
|
||
|
||
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
|
||
// stamps each as uploaded on success.
|
||
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||
var records []string
|
||
var kept []int64
|
||
for _, id := range ids {
|
||
// Skip QSOs not eligible (sent status doesn't match Upload flag).
|
||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||
continue
|
||
}
|
||
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
||
records = append(records, rec)
|
||
kept = append(kept, id)
|
||
}
|
||
}
|
||
if len(records) == 0 {
|
||
return 0
|
||
}
|
||
res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n"))
|
||
if err != nil || !res.OK {
|
||
if err == nil {
|
||
err = errFromResult(res)
|
||
}
|
||
m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err)
|
||
if m.deps.NotifyError != nil {
|
||
m.deps.NotifyError(ServiceLoTW, 0, err)
|
||
}
|
||
return 0
|
||
}
|
||
m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept))
|
||
if m.deps.MarkUploaded != nil {
|
||
for _, id := range kept {
|
||
m.deps.MarkUploaded(ServiceLoTW, id, res.LogID)
|
||
}
|
||
}
|
||
return len(kept)
|
||
}
|
||
|
||
// upload performs the actual push and returns true on success. 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) bool {
|
||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||
// match the configured Upload flag).
|
||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||
return false
|
||
}
|
||
|
||
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 false
|
||
}
|
||
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 false
|
||
}
|
||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||
case ServiceLoTW:
|
||
// LoTW signs the QSO's own station call via TQSL — no override.
|
||
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 = UploadLoTW(ctx, cfg, "", record)
|
||
default:
|
||
return false
|
||
}
|
||
|
||
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 false
|
||
}
|
||
|
||
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)
|
||
}
|
||
return true
|
||
}
|