Files
OpsLog/internal/extsvc/manager.go
T
2026-08-17 13:18:32 +02:00

668 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 12 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<<uint(attempt)) * time.Second // 1s, 2s, 4s…
m.logf("extsvc: %s upload of QSO %d will retry (attempt %d) in %s", svc, id, attempt+2, backoff)
time.AfterFunc(backoff, func() { m.attemptUpload(svc, id, cfg, attempt+1) })
}
}()
}
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()
cfg.Clublog = cfg.Clublog.normalised()
cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
// It answers "is the service even switched on for this profile?" — the
// settings are per-profile, so a service configured under another profile
// looks enabled in the UI of the one and silent in the other.
var on []string
for _, s := range []struct {
name string
cfg ServiceConfig
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
}
}
if len(on) == 0 {
m.logf("extsvc: auto-upload disabled for every service")
} else {
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
}
}
// Config returns the current snapshot.
func (m *Manager) Config() ExternalServices {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// delaySeconds returns a random 60120s 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)
}
// HRDLog — needs the station callsign + the account upload code.
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
m.route(ServiceHRDLog, id, h)
}
// eQSL — needs the account username (callsign) + password.
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e)
}
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
if c := cfg.Cloudlog; c.AutoUpload {
// Say WHY nothing happens when the toggle is on but a field is missing.
// Without this the whole path was silent — the operator saw no upload and
// no log line, with no way to tell "disabled" from "broken".
var missing []string
if c.URL == "" {
missing = append(missing, "URL")
}
if c.APIKey == "" {
missing = append(missing, "API key")
}
if c.StationID == "" {
missing = append(missing, "station ID")
}
if len(missing) > 0 {
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
strings.Join(missing, ", "), id)
} else {
m.route(ServiceCloudlog, id, c)
}
}
}
// 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 {
// Nothing to queue: on-close upload sweeps the whole logbook from the
// database at shutdown (see FlushOnClose), so this QSO is picked up by
// its sent-status then — no in-memory tracking needed.
return
}
m.scheduleUpload(svc, id, cfg)
}
// scheduleUpload uploads now (immediate) or after a random fuse (delayed). Each
// upload runs in its own goroutine (attemptUpload) — never serialised — so a slow
// one never holds up the rest.
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.attemptUpload(svc, id, cfg, 0) })
return
}
m.attemptUpload(svc, id, cfg, 0)
}
// onCloseServices returns the services configured for on-close auto-upload,
// with the minimum credentials to actually run.
func (m *Manager) onCloseServices() []Service {
cfg := m.Config()
var out []Service
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
out = append(out, ServiceQRZ)
}
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
out = append(out, ServiceClublog)
}
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
out = append(out, ServiceLoTW)
}
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
out = append(out, ServiceHRDLog)
}
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL)
}
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog)
}
return out
}
// CloseUploadCount returns how many QSOs across the whole logbook would be
// uploaded at app close (sum over every on-close service). The shutdown
// sequence uses it to decide whether to show the upload step and its label.
func (m *Manager) CloseUploadCount() int {
if m.deps.CloseUploadIDs == nil {
return 0
}
n := 0
for _, svc := range m.onCloseServices() {
n += len(m.deps.CloseUploadIDs(svc))
}
return n
}
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ and
// the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
// as a single TQSL batch, and Club Log goes through its batch endpoint.
// Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
if m.deps.CloseUploadIDs == nil {
return 0
}
cfg := m.Config()
uploaded := 0
for _, svc := range m.onCloseServices() {
ids := m.deps.CloseUploadIDs(svc)
if len(ids) == 0 {
continue
}
switch svc {
case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
case ServiceClublog:
uploaded += m.flushClublogBatch(ids, cfg.Clublog)
case ServiceEQSL:
uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
case ServiceQRZ:
uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
case ServiceHRDLog:
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
}
}
return uploaded
}
// uploadPace is the shortest gap between two consecutive single-QSO uploads in
// an on-close sweep. QRZ, HRDLog and Cloudlog have no batch endpoint — HRDLog's
// NewEntry.aspx keeps only the first record of a multi-record ADIF — so a sweep
// of a freshly imported log is unavoidably one request per contact. It does not
// have to arrive as fast as the link allows, though: that burst is what a
// service reads as a robot, and what got an operator's IP threatened at Club Log
// (see flushClublogBatch). The gap costs nothing in practice, since a round trip
// to any of these already takes longer than it.
const uploadPace = 200 * time.Millisecond
// flushOneByOne uploads ids one request at a time, paced. For the services that
// have no batch API; everything else has its own flush<Service>Batch.
func (m *Manager) flushOneByOne(svc Service, ids []int64, cfg ServiceConfig) int {
uploaded := 0
for i, id := range ids {
if i > 0 {
time.Sleep(uploadPace)
}
if ok, _ := m.upload(svc, id, cfg); ok {
uploaded++
}
}
return uploaded
}
// eqslBatchChunk is how many QSOs go into one ImportADIF.cfm request. eQSL's own
// limit is ten times this (eqslBatchMax); the smaller chunk keeps one refused
// record from taking a thousand others down with it, and keeps the form body
// small enough to be unremarkable.
const eqslBatchChunk = 100
// flushEQSLBatch uploads the on-close eQSL QSOs through ImportADIF.cfm in
// batches instead of one request per contact. Same reasoning as
// flushClublogBatch — eQSL's import endpoint has always taken a whole file, so
// the one-at-a-time loop was making hundreds of requests it never needed to.
func (m *Manager) flushEQSLBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client: UploadEQSLBatch then builds one with a 30 s timeout rather
// than reusing the 20 s budget of a single realtime QSO.
res, err := UploadEQSLBatch(context.Background(), nil, cfg.Username, cfg.Password, cfg.QTHNickname, records)
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: eqsl batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceEQSL, 0, err)
}
} else {
// res.Ignored means eQSL took the file but left records out. Say the
// count out loud: the whole chunk is still marked sent (eQSL never
// says WHICH it dropped, and in practice they are QSOs it already
// had), so the log line is the only trace of the shortfall.
if res.Ignored {
m.logf("extsvc: eqsl batch upload PARTIAL (%d QSOs sent) %s", len(kept), res.Message)
} else {
m.logf("extsvc: eqsl batch upload OK (%d QSOs) %s", len(kept), res.Message)
}
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceEQSL, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceEQSL, id) {
continue
}
// eQSL keeps the QSO's own station call; the account is identified by the
// credentials and the optional QTH nickname — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= eqslBatchChunk {
send()
}
}
send()
return uploaded
}
// clublogBatchChunk is how many QSOs go into one putlogs.php request. Club Log
// dedupes server-side, so chunking is not about correctness — it keeps a single
// malformed record from failing a whole ten-thousand-QSO document, and matches
// what the QSL Manager's bulk upload already uses.
const clublogBatchChunk = 100
// flushClublogBatch uploads the on-close Club Log QSOs through the BATCH
// endpoint (putlogs.php) rather than one realtime.php call each.
//
// It used to walk the ids and call UploadClublog per QSO. On-close upload sweeps
// the WHOLE logbook, so importing an ADIF — or simply switching Club Log on over
// an existing log — turned one app close into hundreds of realtime.php posts.
// That endpoint is reserved for an operator logging contacts as they work them,
// and Club Log blocks the IP of anything that batches through it: an OpsLog user
// was flagged by G7VJR for 185 QSOs in four minutes, which is this loop, not a
// pile-up. Batch upload is the mechanism Club Log provides for exactly this.
func (m *Manager) flushClublogBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client on purpose: UploadClublogADIF then builds one with a 120 s
// timeout. m.deps.Client is the 20 s budget of a single realtime QSO,
// which a hundred-QSO document on a slow link would blow through.
res, err := UploadClublogADIF(context.Background(), nil, cfg, strings.Join(records, "\n"))
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: clublog batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceClublog, 0, err)
}
} else {
m.logf("extsvc: clublog batch upload OK (%d QSOs) %s", len(kept), res.Message)
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceClublog, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
// Skip QSOs not eligible (already sent). The wrong-logbook guard that
// upload() applies per QSO is not repeated here: closeUploadIDs has
// already filtered the sweep down to this logbook's callsign.
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceClublog, id) {
continue
}
// Club Log takes the logbook callsign as its own form field, so the ADIF
// keeps the QSO's own station call (no override) — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= clublogBatchChunk {
send()
}
}
send()
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
}
// Override STATION_CALLSIGN so /P etc. signs against the base cert.
if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); 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.
// upload performs one upload. It returns ok=true when the QSO was uploaded (and
// marked), and retryable=true when it failed in a way worth retrying later (an
// HTTP/service error such as a rate-limit 403) as opposed to a permanent skip
// (not eligible, wrong station callsign, no record).
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, retryable 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, false
}
// Station-callsign guard. Each logbook belongs to one callsign:
// QRZ/LoTW → the ForceStationCallsign (the call this logbook signs as)
// Club Log → the logbook Callsign param
// If the QSO's own STATION_CALLSIGN is a DIFFERENT operator, uploading
// would push it into the wrong logbook (and the force-call option would
// silently relabel it). Block it with a clear error. Portable variants of
// the SAME call (F4BPO/P, FW/F4BPO…) are allowed.
owner := ""
switch svc {
case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog:
owner = cfg.Callsign
case ServiceEQSL:
owner = cfg.Username
}
if owner != "" && m.deps.StationCallOf != nil {
qcall := m.deps.StationCallOf(id)
if qcall != "" && !sameBaseCall(qcall, owner) {
err := fmt.Errorf("station callsign %s does not match %s logbook %s — not uploaded",
strings.ToUpper(qcall), svc, strings.ToUpper(owner))
m.logf("extsvc: %s upload of QSO %d BLOCKED: %v", svc, id, err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false, false
}
}
// One line per attempt, BEFORE the request: a failure that never returns
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
// all, and "did it even try?" is the first question when an upload is missing.
m.logf("extsvc: %s uploading QSO %d…", svc, id)
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, 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, false
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
case ServiceLoTW:
// LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
// so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
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, false
}
res, err = UploadLoTW(ctx, cfg, "", record)
case ServiceHRDLog:
// HRDLog takes the station callsign as a separate param, so the ADIF
// keeps the QSO's own station call (no override), like Club Log.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
case ServiceEQSL:
// eQSL keeps the QSO's own station call; the account is identified by
// the Username + Password, with an optional QTH nickname.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
case ServiceCloudlog:
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
// 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, false
}
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
default:
return false, 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, true // transient (rate-limit / network) → worth a retry
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
return true, false
}