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 } // maxUploadAttempts bounds retries of a transient upload failure. const maxUploadAttempts = 4 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())), } } // attemptUpload uploads a QSO in its OWN goroutine and, on a TRANSIENT failure // (rate-limit / network), re-arms itself with exponential back-off. Each upload is // independent — never serialised through a shared worker, because a single slow // upload (LoTW signs via TQSL; a service on a 30 s timeout) would otherwise block // every following QSO's upload and strand them all at "R" (the regression that hit // the operator on the newest build while everyone on the old concurrent path was // fine). func (m *Manager) attemptUpload(svc Service, id int64, cfg ServiceConfig, attempt int) { go func() { ok, retryable := m.upload(svc, id, cfg) if !ok && retryable && attempt+1 < maxUploadAttempts { backoff := time.Duration(1<