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.
127 lines
4.1 KiB
Go
127 lines
4.1 KiB
Go
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
|
|
}
|