feat: Implemented the download of eQSL
This commit is contained in:
@@ -245,6 +245,7 @@ const (
|
|||||||
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
|
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
|
||||||
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
|
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
|
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
|
// 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))
|
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
||||||
// (last-download date already stored right after the fetch above)
|
// (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:
|
default:
|
||||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,91 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
|
|||||||
return msg, nil
|
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. `<A HREF="/qslcard/downloadedfiles/xxxx.adi">`.
|
||||||
|
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
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
func authErrEQSL(body string) string {
|
func authErrEQSL(body string) string {
|
||||||
|
|||||||
@@ -1890,6 +1890,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
|||||||
return nil
|
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
|
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
|
||||||
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
|
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user