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) } }