External services (QRZ/Clublog/LoTW) + QSL Manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 00:52:10 +02:00
parent 33a7b6c4ac
commit 8f1ad126ac
9 changed files with 456 additions and 27 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ const (
type ServiceConfig struct {
APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password
Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
+57
View File
@@ -4,6 +4,9 @@ import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -11,6 +14,60 @@ import (
"time"
)
// lotwReportURL is LoTW's confirmation-report endpoint. It returns an ADIF
// document of the user's QSOs (optionally only confirmed ones).
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
// returned — used for incremental "Last download" updates.
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("lotw: website login (username/password) not set")
}
q := url.Values{}
q.Set("login", user)
q.Set("password", cfg.Password)
q.Set("qso_query", "1")
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
if s := strings.TrimSpace(since); s != "" {
q.Set("qso_qslsince", s)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
if err != nil {
return "", fmt.Errorf("lotw: build request: %w", err)
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("lotw: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return "", fmt.Errorf("lotw: read response: %w", err)
}
text := string(body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
}
// LoTW returns a plain-text error (not ADIF) on bad login.
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
msg := strings.TrimSpace(text)
if len(msg) > 200 {
msg = msg[:200]
}
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
}
return text, nil
}
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
// plain HTTP API — every QSO must be signed with the station certificate
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run
+78
View File
@@ -1062,6 +1062,84 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
}
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, strftime('%Y-%m-%dT%H:%M', qso_date), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
out[DedupeKey(call, when, band, mode)] = id
}
return out, rows.Err()
}
// ConfirmedSets captures which DXCC / band / slot combinations are already
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
type ConfirmedSets struct {
DXCC map[int]bool // dxcc entity confirmed
Band map[string]bool // "dxcc|band"
Slot map[string]bool // "dxcc|band|mode"
}
// SlotKey / BandKey build the composite keys used in ConfirmedSets.
func BandKey(dxcc int, band string) string { return fmt.Sprintf("%d|%s", dxcc, strings.ToLower(band)) }
func SlotKey(dxcc int, band, mode string) string {
return fmt.Sprintf("%d|%s|%s", dxcc, strings.ToLower(band), strings.ToUpper(mode))
}
// ConfirmedSlots returns the set of confirmed DXCC/band/slot combos. A QSO
// counts as confirmed when any received flag (LoTW, paper, eQSL) is "Y".
func (r *Repo) ConfirmedSlots(ctx context.Context) (ConfirmedSets, error) {
sets := ConfirmedSets{DXCC: map[int]bool{}, Band: map[string]bool{}, Slot: map[string]bool{}}
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(dxcc,0), LOWER(COALESCE(band,'')), UPPER(COALESCE(mode,''))
FROM qso
WHERE lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'`)
if err != nil {
return sets, err
}
defer rows.Close()
for rows.Next() {
var dxcc int
var band, mode string
if err := rows.Scan(&dxcc, &band, &mode); err != nil {
return sets, err
}
if dxcc == 0 {
continue
}
sets.DXCC[dxcc] = true
sets.Band[BandKey(dxcc, band)] = true
sets.Slot[SlotKey(dxcc, band, mode)] = true
}
return sets, rows.Err()
}
// 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 {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET lotw_rcvd = 'Y', lotw_rcvd_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark lotw confirmed %d: %w", id, err)
}
return nil
}
// scanner is what both *sql.Row and *sql.Rows satisfy for our needs.
type scanner interface {
Scan(dest ...any) error