feat(clublog): the matches come home, and Club Log lends its call list
Two new QSO columns, clublog_qso_download_status/date — a Club Log MATCH, the service's own confirmation (both stations uploaded the QSO, paired within 15 minutes). Full promoted-column lockstep: migration 0031, repo insert/scan, ADIF dictionary + import + export (app-defined CLUBLOG_QSO_DOWNLOAD_*), table columns, filter builder, bulk edit and the QSO editor's Club Log row. The QSL Manager's Club Log entry now actually downloads: getmatches.php with the existing account settings and the embedded application key, incremental via the match-completion date filter, matched call+band+mode ±15 min with a mode-blind fallback because Club Log reports 'false' for modes it cannot infer. Matches always exist on both sides, so unmatched ones are listed rather than skeleton-added. And Super Check Partial can merge Club Log's weekly SCP list (~180k calls worked on the air in the last 3 years) with MASTER.SCP — an opt-in checkbox under the SCP setting.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Club Log's log-matching API. A "match" is a QSO that BOTH stations uploaded
|
||||
// to Club Log, paired within ±15 minutes — Club Log's own equivalent of a LoTW
|
||||
// confirmation. getmatches.php returns a JSON array of 5-element arrays:
|
||||
//
|
||||
// [["G0LGJ/M","223","2005-07-16 08:00:00","20","CW"], …]
|
||||
// callsign dxcc qso datetime (UTC) band mode ("false" when unknown)
|
||||
//
|
||||
// The optional start date filters on when Club Log COMPLETED the match (not
|
||||
// the QSO date), which is exactly what an incremental "since last download"
|
||||
// pull wants.
|
||||
const clublogMatchesURL = "https://clublog.org/getmatches.php"
|
||||
|
||||
// ClublogMatch is one confirmed pairing from getmatches.php.
|
||||
type ClublogMatch struct {
|
||||
Callsign string
|
||||
DXCC int
|
||||
When time.Time
|
||||
Band string // ADIF band ("20m", "70cm"); "" if the id is unknown
|
||||
Mode string // "" when Club Log doesn't know it
|
||||
}
|
||||
|
||||
// clublogBandNames maps Club Log's numeric band ids to ADIF band names. The
|
||||
// ids are the wavelength number; the only trap is that ids past the metre
|
||||
// bands are centimetres (the docs' own example: 70 = 70CM).
|
||||
var clublogBandNames = map[string]string{
|
||||
"2200": "2200m", "630": "630m", "160": "160m", "80": "80m", "60": "60m",
|
||||
"40": "40m", "30": "30m", "20": "20m", "17": "17m", "15": "15m",
|
||||
"12": "12m", "10": "10m", "8": "8m", "6": "6m", "5": "5m", "4": "4m",
|
||||
"2": "2m", "70": "70cm", "23": "23cm", "13": "13cm", "9": "9cm", "3": "3cm",
|
||||
}
|
||||
|
||||
// DownloadClublogMatches pulls the account's log matches for cfg.Callsign,
|
||||
// optionally only those Club Log completed since sinceDate ("2006-01-02").
|
||||
func DownloadClublogMatches(ctx context.Context, client *http.Client, cfg ServiceConfig, sinceDate string) ([]ClublogMatch, error) {
|
||||
email := strings.TrimSpace(cfg.Email)
|
||||
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||
switch {
|
||||
case email == "":
|
||||
return nil, fmt.Errorf("clublog: account email not set")
|
||||
case cfg.Password == "":
|
||||
return nil, fmt.Errorf("clublog: password not set")
|
||||
case call == "":
|
||||
return nil, fmt.Errorf("clublog: callsign not set")
|
||||
}
|
||||
v := url.Values{}
|
||||
v.Set("api", clublogAppAPIKey)
|
||||
v.Set("email", email)
|
||||
v.Set("password", cfg.Password)
|
||||
v.Set("callsign", call)
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(sinceDate)); err == nil {
|
||||
v.Set("startyear", strconv.Itoa(t.Year()))
|
||||
v.Set("startmonth", strconv.Itoa(int(t.Month())))
|
||||
v.Set("startday", strconv.Itoa(t.Day()))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogMatchesURL+"?"+v.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
text := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if looksLikeHTML(text) || len(text) > 300 {
|
||||
return nil, fmt.Errorf("clublog: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("clublog: HTTP %d: %s", resp.StatusCode, text)
|
||||
}
|
||||
if looksLikeHTML(text) {
|
||||
return nil, fmt.Errorf("clublog: got a web page instead of matches — check email/password/callsign")
|
||||
}
|
||||
var raw [][]any
|
||||
if err := json.Unmarshal([]byte(text), &raw); err != nil {
|
||||
return nil, fmt.Errorf("clublog: bad matches JSON: %w", err)
|
||||
}
|
||||
str := func(x any) string {
|
||||
switch t := x.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
out := make([]ClublogMatch, 0, len(raw))
|
||||
for _, rec := range raw {
|
||||
if len(rec) < 5 {
|
||||
continue
|
||||
}
|
||||
m := ClublogMatch{Callsign: strings.ToUpper(strings.TrimSpace(str(rec[0])))}
|
||||
m.DXCC, _ = strconv.Atoi(str(rec[1]))
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", str(rec[2])); err == nil {
|
||||
m.When = t.UTC()
|
||||
}
|
||||
m.Band = clublogBandNames[strings.TrimSpace(str(rec[3]))]
|
||||
if md := strings.TrimSpace(str(rec[4])); md != "" && !strings.EqualFold(md, "false") {
|
||||
m.Mode = strings.ToUpper(md)
|
||||
}
|
||||
if m.Callsign == "" || m.When.IsZero() {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user