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 }