package extsvc import ( "context" "fmt" "math/rand" "net/http" "strings" "sync" "time" ) // baseCall extracts the operator's base callsign from a possibly-affixed call: // for slashed forms (F4BPO/P, FW/F4BPO, 9A/F4BPO/P) it returns the longest // token, which is the real call; otherwise the call itself. Upper-cased. func baseCall(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if !strings.Contains(s, "/") { return s } best := "" for _, part := range strings.Split(s, "/") { if len(part) > len(best) { best = part } } return best } // sameBaseCall reports whether two callsigns belong to the same operator, // ignoring portable prefixes/suffixes (F4BPO/P == F4BPO, FW/F4BPO == F4BPO). func sameBaseCall(a, b string) bool { return baseCall(a) == baseCall(b) } // SameBaseCall is the exported form of sameBaseCall, so the host app can apply // the same "same operator?" rule when filtering an on-close upload batch by the // active logbook's callsign. func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) } // 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 // StationCallOf returns the QSO's STATION_CALLSIGN. Used to guard against // uploading a QSO into a logbook for a different callsign (the force-call // option would otherwise silently relabel it). "" → no station call known. StationCallOf func(id int64) string // CloseUploadIDs returns the QSO ids to upload for a service when the app // closes — scanning the WHOLE logbook, not just this session: LoTW returns // rows whose lotw_sent matches the configured status set; QRZ/Club Log // return anything not yet "Y". This is what makes an imported ADIF (old // QSOs still marked unsent) upload on close. nil → nothing to do. CloseUploadIDs func(svc Service) []int64 // 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 // uploadCh serialises immediate auto-uploads through a single worker. Firing a // goroutine per QSO meant a pileup / ADIF-import burst hit a service with dozens // of concurrent requests at once — Club Log's nginx answers that with 403, the // upload is counted as failed and the QSO stays at "R" despite the others going // through. One-at-a-time with a small gap keeps every upload under the limit. uploadCh chan uploadJob } // uploadJob is one queued auto-upload. type uploadJob struct { svc Service id int64 cfg ServiceConfig attempt int // 0 on first try; incremented on each retry } // uploadGap spaces serialized uploads so a burst never trips a service's per-IP // rate limiter. maxUploadAttempts bounds retries of a transient failure. const ( uploadGap = 250 * time.Millisecond maxUploadAttempts = 4 ) func NewManager(deps Deps) *Manager { if deps.Client == nil { deps.Client = &http.Client{Timeout: 20 * time.Second} } m := &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())), uploadCh: make(chan uploadJob, 4096), } go m.uploadWorker() return m } // uploadWorker drains the queue one upload at a time, spacing them so a burst of // freshly-logged QSOs can't hammer (and get 403'd by) a service. A transient // failure is re-queued with an exponential back-off, so a QSO that hit a // momentary rate-limit still ends up marked instead of stuck at "R". func (m *Manager) uploadWorker() { for job := range m.uploadCh { ok, retryable := m.upload(job.svc, job.id, job.cfg) if !ok && retryable && job.attempt+1 < maxUploadAttempts { next := job next.attempt++ backoff := time.Duration(1<