From 206a6bff0902623a8dc7019daf38f262c1ef7cf7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Jul 2026 13:16:04 +0200 Subject: [PATCH] feat: Implemented the download of eQSL --- app.go | 92 +++++++++++++++++++++++++++++++++++++++++ internal/extsvc/eqsl.go | 85 +++++++++++++++++++++++++++++++++++++ internal/qso/qso.go | 13 ++++++ 3 files changed, 190 insertions(+) diff --git a/app.go b/app.go index fbe6209..278990b 100644 --- a/app.go +++ b/app.go @@ -245,6 +245,7 @@ const ( keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download) keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull + keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull ) // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload @@ -6905,6 +6906,97 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) // (last-download date already stored right after the fetch above) + case extsvc.ServiceEQSL: + sinceDate := resolveSince(keyExtEQSLLastDownload) + if sinceDate != "" { + emit("Downloading eQSL Inbox (confirmations received since " + sinceDate + ")…") + } else { + emit("Downloading full eQSL Inbox (all received confirmations)…") + } + adifText, err := extsvc.DownloadEQSLConfirmations(ctx, nil, cfg.EQSL, sinceDate) + if err != nil { + emit("Download failed: " + err.Error()) + done(matched, total) + return + } + keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) + if kerr != nil { + emit("Error reading local log: " + kerr.Error()) + done(matched, total) + return + } + // eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are), + // so NEW is judged only against other eQSL confirmations. + sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"}) + var items []ConfirmationItem + var unmatched []string + perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { + q, ok := adif.RecordToQSO(rec) + if !ok { + return nil + } + total++ + // eQSL stamps the confirmation date in QSLRDATE (fall back to today). + date := rec["qslrdate"] + if date == "" { + date = time.Now().UTC().Format("20060102") + } + a.enrichContactedFromCty(&q) + key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) + if id, found := keyIDs[key]; found { + if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil { + matched++ + } + } else if addNotFound { + q.EQSLSent = "Y" + q.EQSLRcvd = "Y" + q.EQSLRcvdDate = date + if newID, e := a.qso.Add(ctx, q); e == nil { + keyIDs[key] = newID + added++ + } + } else { + unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s", + q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode)) + } + dxccNum := 0 + if q.DXCC != nil { + dxccNum = *q.DXCC + } + it := ConfirmationItem{ + Callsign: q.Callsign, + QSODate: q.QSODate.UTC().Format(time.RFC3339), + Band: q.Band, Mode: q.Mode, Country: q.Country, + } + if dxccNum != 0 { + it.NewDXCC = !sets.DXCC[dxccNum] + it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)] + it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] + sets.DXCC[dxccNum] = true + sets.Band[qso.BandKey(dxccNum, q.Band)] = true + sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true + } + items = append(items, it) + return nil + }) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items) + } + if perr != nil { + emit("Parse error: " + perr.Error()) + } + if addNotFound { + emit(fmt.Sprintf("Matched %d, added %d (of %d eQSL confirmation(s))", matched, added, total)) + } else { + emit(fmt.Sprintf("Matched %d of %d eQSL confirmation(s)", matched, total)) + } + for _, u := range unmatched { + emit(" ⚠ no local QSO for: " + u) + } + if a.settings != nil { + a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02")) + } + default: emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) } diff --git a/internal/extsvc/eqsl.go b/internal/extsvc/eqsl.go index d6a8f9d..f4272d3 100644 --- a/internal/extsvc/eqsl.go +++ b/internal/extsvc/eqsl.go @@ -53,6 +53,91 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) return msg, nil } +// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of +// the account's *received* eQSLs (confirmations) and returns an HTML page that +// links to the generated .adi file; we then fetch that file (two-step API). +const ( + eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm" + eqslBaseURL = "https://www.eQSL.cc" +) + +// eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply, +// e.g. ``. +var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`) + +// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs = +// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since +// then, incremental "last download"); qthNick comes from the config when the +// account has several QTH profiles. +func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) { + user := strings.ToUpper(strings.TrimSpace(cfg.Username)) + if user == "" || strings.TrimSpace(cfg.Password) == "" { + return "", fmt.Errorf("eqsl: username/password not set") + } + if client == nil { + client = &http.Client{Timeout: 120 * time.Second} + } + q := url.Values{} + q.Set("UserName", user) + q.Set("Password", cfg.Password) + if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" { + q.Set("QTHNickname", nick) + } + if s := eqslRcvdSince(since); s != "" { + q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM + } + + // Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it. + page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode()) + if err != nil { + return "", err + } + if reason := authErrEQSL(page); reason != "" { + return "", fmt.Errorf("eqsl: %s", reason) + } + m := eqslAdiHrefRe.FindStringSubmatch(page) + if m == nil { + // eQSL reports problems inline ("Error: …", or "no QSLs" wording). + return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page)) + } + + // Step 2: fetch the generated .adi file. + adif, err := eqslGet(ctx, client, eqslBaseURL+m[1]) + if err != nil { + return "", fmt.Errorf("eqsl: fetch adif: %w", err) + } + return adif, nil +} + +// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on +// a non-200 status. +func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return "", fmt.Errorf("eqsl: build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("eqsl: request failed: %w", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("eqsl: http %d", resp.StatusCode) + } + return string(b), nil +} + +// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format +// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull). +func eqslRcvdSince(since string) string { + s := strings.ReplaceAll(strings.TrimSpace(since), "-", "") + if len(s) == 8 { // YYYYMMDD → append 0000 (00:00) + return s + "0000" + } + return s +} + // 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 { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 5f4d166..9a766af 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1890,6 +1890,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro return nil } +// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after +// an eQSL Inbox download. date is an ADIF YYYYMMDD string. +func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?, + updated_at = ? WHERE id = ?`, + date, db.NowISO(), id) + if err != nil { + return fmt.Errorf("mark eqsl confirmed %d: %w", id, err) + } + return nil +} + // MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO // after a LoTW confirmation download. date is an ADIF YYYYMMDD string. func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {