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 }