diff --git a/app.go b/app.go index a263888..5e80ae4 100644 --- a/app.go +++ b/app.go @@ -195,6 +195,12 @@ const ( keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload" keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode" + keyExtEQSLUsername = "extsvc.eqsl.username" + keyExtEQSLPassword = "extsvc.eqsl.password" + keyExtEQSLQTHNick = "extsvc.eqsl.qth_nickname" + keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload" + keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode" + keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path" @@ -4873,7 +4879,8 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog, keyExtLoTWAutoUpload, keyExtLoTWUploadMode, keyExtLoTWUsername, keyExtLoTWWebPassword, - keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode) + keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, + keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode) if err != nil { return out } @@ -4930,6 +4937,19 @@ func (a *App) loadExternalServices() extsvc.ExternalServices { out.HRDLog.Callsign = p.Callsign } } + out.EQSL = extsvc.ServiceConfig{ + Username: m[keyExtEQSLUsername], + Password: m[keyExtEQSLPassword], + QTHNickname: m[keyExtEQSLQTHNick], + AutoUpload: m[keyExtEQSLAutoUpload] == "1", + UploadMode: extsvc.UploadMode(m[keyExtEQSLUploadMode]), + } + // Default the eQSL username to the active profile's call when unset. + if out.EQSL.Username == "" && a.profiles != nil { + if p, perr := a.profiles.Active(a.ctx); perr == nil { + out.EQSL.Username = p.Callsign + } + } return out } @@ -4982,6 +5002,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { if cfg.HRDLog.AutoUpload { hlAuto = "1" } + eqMode := modeOf(cfg.EQSL.UploadMode) + eqAuto := "0" + if cfg.EQSL.AutoUpload { + eqAuto = "1" + } scope := a.profileScope() // write under the active profile's prefix for k, v := range map[string]string{ keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey), @@ -5011,6 +5036,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error { keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code), keyExtHRDLogAutoUpload: hlAuto, keyExtHRDLogUploadMode: hlMode, + + keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)), + keyExtEQSLPassword: cfg.EQSL.Password, + keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname), + keyExtEQSLAutoUpload: eqAuto, + keyExtEQSLUploadMode: eqMode, } { if err := a.settings.Set(a.ctx, scope+k, v); err != nil { return err @@ -5044,6 +5075,11 @@ func (a *App) TestHRDLogUpload() (string, error) { return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog) } +// TestEQSLUpload validates the eQSL credentials with a real (no-op) request. +func (a *App) TestEQSLUpload() (string, error) { + return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL) +} + // ── QSL Manager (manual upload) ──────────────────────────────────────── // uploadColumnFor maps a service id to its QSO sent-status column. @@ -5057,6 +5093,8 @@ func uploadColumnFor(service string) string { return "lotw_sent" case extsvc.ServiceHRDLog: return "hrdlog_qso_upload_status" + case extsvc.ServiceEQSL: + return "eqsl_sent" } return "" } @@ -5197,6 +5235,8 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec) case extsvc.ServiceHRDLog: res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec) + case extsvc.ServiceEQSL: + res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec) default: res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec) } @@ -5770,6 +5810,8 @@ func (a *App) uploadOwnerCall(svc extsvc.Service) string { owner = cfg.Clublog.Callsign case extsvc.ServiceHRDLog: owner = cfg.HRDLog.Callsign + case extsvc.ServiceEQSL: + owner = cfg.EQSL.Username } owner = strings.ToUpper(strings.TrimSpace(owner)) if owner == "" && a.profiles != nil { @@ -5866,6 +5908,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool { return false } return true + case extsvc.ServiceEQSL: + if strings.EqualFold(q.EQSLSent, "Y") { + applog.Printf("extsvc: QSO %d not eligible for eqsl — EQSLSent already %q (set Confirmations default to N to upload)", id, q.EQSLSent) + return false + } + return true case extsvc.ServiceLoTW: for _, f := range a.loadExternalServices().LoTW.UploadFlags { if strings.EqualFold(q.LOTWSent, f) { @@ -5906,6 +5954,12 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) { applog.Printf("extsvc: mark hrdlog uploaded %d: %v", id, err) } } + case extsvc.ServiceEQSL: + if a.qso != nil { + if err := a.qso.MarkEQSLSent(a.ctx, id, date); err != nil { + applog.Printf("extsvc: mark eqsl sent %d: %v", id, err) + } + } } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "extsvc:uploaded", map[string]any{ diff --git a/app_secret.go b/app_secret.go index d66ef79..dc511e5 100644 --- a/app_secret.go +++ b/app_secret.go @@ -25,6 +25,7 @@ var sensitiveSettingKeys = map[string]bool{ keyExtLoTWKeyPassword: true, keyExtLoTWWebPassword: true, keyExtHRDLogCode: true, + keyExtEQSLPassword: true, } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } diff --git a/frontend/src/components/QSLManagerModal.tsx b/frontend/src/components/QSLManagerModal.tsx index 77c8296..060e5cf 100644 --- a/frontend/src/components/QSLManagerModal.tsx +++ b/frontend/src/components/QSLManagerModal.tsx @@ -24,6 +24,7 @@ const SERVICES = [ { v: 'qrz', label: 'QRZ.com' }, { v: 'clublog', label: 'Club Log' }, { v: 'hrdlog', label: 'HRDLog.net' }, + { v: 'eqsl', label: 'eQSL.cc' }, { v: 'lotw', label: 'LoTW' }, { v: 'pota', label: 'POTA hunter log' }, { v: 'paper', label: 'Paper QSL' }, diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 2827522..1c5d8f8 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -31,7 +31,7 @@ import { GetTelemetryEnabled, SetTelemetryEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled, GetQSLDefaults, SaveQSLDefaults, - GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, + GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, GetPOTAToken, SavePOTAToken, TestLoTWUpload, ListTQSLStationLocations, ComputeStationInfo, @@ -747,21 +747,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // wired today. upload_mode is 'immediate' | 'delayed' (per-service). type ExtServiceCfg = { api_key: string; email: string; username: string; password: string; callsign: string; - code: string; + code: string; qth_nickname: string; force_station_callsign: string; tqsl_path: string; station_location: string; key_password: string; upload_flags: string[]; write_log: boolean; auto_upload: boolean; upload_mode: string; }; - type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg }; + type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg }; const emptyExtCfg = (): ExtServiceCfg => ({ - api_key: '', email: '', username: '', password: '', callsign: '', code: '', + api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', upload_flags: ['N', 'R'], write_log: false, auto_upload: false, upload_mode: 'immediate', }); const [extSvc, setExtSvc] = useState({ - qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), + qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), }); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTesting, setQrzTesting] = useState(false); @@ -771,10 +771,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [lotwTesting, setLotwTesting] = useState(false); const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTesting, setHrdlogTesting] = useState(false); + const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); + const [eqslTesting, setEqslTesting] = useState(false); const [stationLocations, setStationLocations] = useState([]); // Active tab in the External Services panel — lifted here because // PANELS[selected]() is called as a function, so panels can't hold hooks. - const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz'); + const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz'); // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). const [potaToken, setPotaToken] = useState(''); const [potaBusy, setPotaBusy] = useState(false); @@ -2607,8 +2609,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan { k: 'qrz', label: 'QRZ.COM', ready: true }, { k: 'clublog', label: 'CLUBLOG', ready: true }, { k: 'hrdlog', label: 'HRDLOG.NET', ready: true }, - { k: 'eqsl', label: 'EQSL' }, - { k: 'hamqth', label: 'HAMQTH' }, + { k: 'eqsl', label: 'EQSL', ready: true }, { k: 'lotw', label: 'LOTW', ready: true }, { k: 'pota', label: 'POTA', ready: true }, ]; @@ -2679,6 +2680,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan } } + const eqsl = extSvc.eqsl; + const setEqsl = (patch: Partial) => + setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } })); + + async function testEqsl() { + setEqslTesting(true); + setEqslTest(null); + try { + await SaveExternalServices(extSvc as any); + const msg = await TestEQSLUpload(); + setEqslTest({ ok: true, msg }); + } catch (e: any) { + setEqslTest({ ok: false, msg: String(e?.message ?? e) }); + } finally { + setEqslTesting(false); + } + } + const lotw = extSvc.lotw; const setLotw = (patch: Partial) => setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } })); @@ -2913,6 +2932,72 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + ) : extSvcTab === 'eqsl' ? ( +
+
+ + setEqsl({ username: e.target.value.toUpperCase() })} + placeholder="your eQSL.cc login (callsign)" + className="font-mono text-xs" + /> + + setEqsl({ password: e.target.value })} + placeholder="eQSL.cc account password" + className="text-xs" + /> + + setEqsl({ qth_nickname: e.target.value })} + placeholder="optional — required only if your eQSL account has several QTHs" + className="text-xs" + /> +
+
+ The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one. +
+ +
+ + +
+ + +
+ +
+ + {eqslTest && ( + + {eqslTest.msg} + + )} +
+
+
) : extSvcTab === 'lotw' ? (
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f4f325e..ae4fb54 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -471,6 +471,8 @@ export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise; +export function TestEQSLUpload():Promise; + export function TestEmail(arg1:string):Promise; export function TestHRDLogUpload():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 23440cc..ddb4a1a 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -914,6 +914,10 @@ export function TestClublogUpload() { return window['go']['main']['App']['TestClublogUpload'](); } +export function TestEQSLUpload() { + return window['go']['main']['App']['TestEQSLUpload'](); +} + export function TestEmail(arg1) { return window['go']['main']['App']['TestEmail'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index f2607c3..9ac0630 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -670,6 +670,7 @@ export namespace extsvc { password: string; callsign: string; code: string; + qth_nickname: string; force_station_callsign: string; tqsl_path: string; station_location: string; @@ -691,6 +692,7 @@ export namespace extsvc { this.password = source["password"]; this.callsign = source["callsign"]; this.code = source["code"]; + this.qth_nickname = source["qth_nickname"]; this.force_station_callsign = source["force_station_callsign"]; this.tqsl_path = source["tqsl_path"]; this.station_location = source["station_location"]; @@ -706,6 +708,7 @@ export namespace extsvc { clublog: ServiceConfig; lotw: ServiceConfig; hrdlog: ServiceConfig; + eqsl: ServiceConfig; static createFrom(source: any = {}) { return new ExternalServices(source); @@ -717,6 +720,7 @@ export namespace extsvc { this.clublog = this.convertValues(source["clublog"], ServiceConfig); this.lotw = this.convertValues(source["lotw"], ServiceConfig); this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig); + this.eqsl = this.convertValues(source["eqsl"], ServiceConfig); } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/extsvc/eqsl.go b/internal/extsvc/eqsl.go new file mode 100644 index 0000000..d6a8f9d --- /dev/null +++ b/internal/extsvc/eqsl.go @@ -0,0 +1,161 @@ +package extsvc + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded +// POST (or URL params) with the account credentials and the ADIF content. +const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm" + +// eqslResultRe extracts "Result: X out of Y records added" from the reply. +var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`) + +// eqslPost performs the import POST and returns the raw response body. eQSL +// replies HTTP 200 with a plain-text/HTML body for both success and errors; +// callers classify it via the markers below. +func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) { + form := url.Values{} + form.Set("EQSL_USER", user) + form.Set("EQSL_PSWD", pswd) + form.Set("ADIFData", adif) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode())) + if err != nil { + return "", fmt.Errorf("eqsl: build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("eqsl: request failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + msg := strings.TrimSpace(string(body)) + if resp.StatusCode != http.StatusOK { + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg) + } + return msg, nil +} + +// authErrEQSL returns a reason when the response signals bad credentials, else +// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd". +func authErrEQSL(body string) string { + if strings.Contains(strings.ToLower(body), "no match on eqsl") { + return "invalid username or password" + } + return "" +} + +// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF +// record when nick is set, so eQSL files the QSO under the right QTH profile +// (required when the account has more than one). ADIF field order is free, so +// prepending before the rest of the record is valid. +func eqslRecordWithNickname(record, nick string) string { + nick = strings.TrimSpace(nick) + if nick == "" { + return record + } + return fmt.Sprintf("%s%s", len(nick), nick, record) +} + +// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick +// is the optional eQSL QTH nickname. +// +// eQSL replies with text: "Result: 1 out of 1 records added" on success, +// "Bad record: Duplicate" for an already-present QSO (treated as success so +// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad +// credentials. +func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) { + user = strings.ToUpper(strings.TrimSpace(user)) + if user == "" { + return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set") + } + if strings.TrimSpace(pswd) == "" { + return UploadResult{}, fmt.Errorf("eqsl: password not set") + } + if strings.TrimSpace(adifRecord) == "" { + return UploadResult{}, fmt.Errorf("eqsl: empty adif record") + } + + body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick)) + if err != nil { + return UploadResult{OK: false, Message: body}, err + } + + b := strings.ToLower(body) + if reason := authErrEQSL(body); reason != "" { + return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason) + } + if strings.Contains(b, "duplicate") { + return UploadResult{OK: true, Message: "already in logbook"}, nil + } + if m := eqslResultRe.FindStringSubmatch(body); m != nil { + added, _ := strconv.Atoi(m[1]) + if added >= 1 { + return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil + } + // "0 out of N" — eQSL accepted nothing; surface why if it said so. + reason := eqslReason(body) + return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason) + } + reason := eqslReason(body) + return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason) +} + +// eqslReason trims an eQSL reply to a short human-readable reason: the first +// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body +// (capped), else a generic phrase. +func eqslReason(body string) string { + for _, line := range strings.Split(body, "\n") { + l := strings.TrimSpace(line) + ll := strings.ToLower(l) + if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") { + return l + } + } + b := strings.TrimSpace(body) + if b == "" { + return "upload rejected" + } + if len(b) > 200 { + b = b[:200] + } + return b +} + +// TestEQSL validates the configured eQSL credentials with a REAL request: it +// posts an empty ADIF so nothing is inserted, then checks for the bad-login +// marker. Anything else means the credentials were accepted. +func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) { + user := strings.ToUpper(strings.TrimSpace(cfg.Username)) + if user == "" { + return "", fmt.Errorf("eqsl: username (callsign) not set") + } + if strings.TrimSpace(cfg.Password) == "" { + return "", fmt.Errorf("eqsl: password not set") + } + body, err := eqslPost(ctx, client, user, cfg.Password, "") + if err != nil { + return "", err + } + if reason := authErrEQSL(body); reason != "" { + return "", fmt.Errorf("eqsl: %s", reason) + } + return fmt.Sprintf("Credentials accepted — %s", user), nil +} diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 7851436..5294bb9 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -32,6 +32,7 @@ const ( ServiceClublog Service = "clublog" // Club Log real-time upload ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL) ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload + ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload ) // UploadMode selects when an auto-upload fires after a QSO is saved. @@ -67,6 +68,7 @@ type ServiceConfig struct { Password string `json:"password"` // Club Log account / LoTW website password Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign Code string `json:"code"` // HRDLog: account upload code + QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several) ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name @@ -84,6 +86,8 @@ func (c ServiceConfig) normalised() ServiceConfig { c.Email = strings.TrimSpace(c.Email) c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign)) c.Code = strings.TrimSpace(c.Code) + c.Username = strings.TrimSpace(c.Username) + c.QTHNickname = strings.TrimSpace(c.QTHNickname) c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign)) c.TQSLPath = strings.TrimSpace(c.TQSLPath) c.StationLocation = strings.TrimSpace(c.StationLocation) @@ -115,6 +119,7 @@ type ExternalServices struct { Clublog ServiceConfig `json:"clublog"` LoTW ServiceConfig `json:"lotw"` HRDLog ServiceConfig `json:"hrdlog"` + EQSL ServiceConfig `json:"eqsl"` } // UploadResult is the outcome of a single upload attempt. diff --git a/internal/extsvc/manager.go b/internal/extsvc/manager.go index 215fdb7..f0d7f5e 100644 --- a/internal/extsvc/manager.go +++ b/internal/extsvc/manager.go @@ -116,6 +116,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) { cfg.Clublog = cfg.Clublog.normalised() cfg.LoTW = cfg.LoTW.normalised() cfg.HRDLog = cfg.HRDLog.normalised() + cfg.EQSL = cfg.EQSL.normalised() m.cfg = cfg } @@ -156,6 +157,10 @@ func (m *Manager) OnQSOLogged(id int64) { 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) + } } // route sends a logged QSO down the configured timing path: queue it for the @@ -198,6 +203,9 @@ func (m *Manager) onCloseServices() []Service { 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) + } return out } @@ -251,6 +259,12 @@ func (m *Manager) FlushOnClose() int { uploaded++ } } + case ServiceEQSL: + for _, id := range ids { + if m.upload(svc, id, cfg.EQSL) { + uploaded++ + } + } } } return uploaded @@ -319,6 +333,8 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool { 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) @@ -374,6 +390,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool { return 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 + } + res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record) default: return false }