Files
OpsLog/internal/scp/scp.go
T
rouggy 3cb8096141 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.
2026-08-31 10:14:52 +02:00

339 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package scp provides Super Check Partial (SCP) and N+1 callsign suggestions
// from the community MASTER.SCP master file (supercheckpartial.com) — the same
// aid contest loggers (N1MM, DXLog, Log4OM) show to catch/correct a busted call.
//
// - Partial (SCP): every master call that CONTAINS what you've typed, so a
// mistyped or half-copied call surfaces the real ones.
// - N+1: master calls exactly one edit away (one char substituted, added or
// removed) from the full call you typed — the classic "did I bust it?" check.
//
// The list is downloaded once and cached on disk so it survives restarts.
package scp
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// masterURL is the community Super Check Partial master file: one callsign per
// line, '#'-prefixed header lines. ~50k+ active contest/DX calls.
const masterURL = "https://www.supercheckpartial.com/MASTER.SCP"
// clublogURL is Club Log's own SCP list, rebuilt weekly from real DX logs:
// every call from a current entity with 40+ QSOs in the last 3 years (~180k).
// Broader than MASTER.SCP (which leans contest), so the manager merges the two
// when the Club Log source is enabled.
const clublogURL = "https://cdn.clublog.org/clublog.scp.gz"
const cacheFile = "MASTER.SCP"
const clublogCacheFile = "CLUBLOG.SCP" // stored decompressed
// Result is the two suggestion lists for a typed fragment.
type Result struct {
Partial []string `json:"partial"` // master calls containing the fragment
NPlus1 []string `json:"nplus1"` // master calls one edit away from the full call
}
// Manager holds the parsed call list + cache location.
type Manager struct {
mu sync.RWMutex
calls []string // UPPER, de-duplicated, sorted
updated time.Time // when the cache was last refreshed
dir string
client *http.Client
clublog atomic.Bool // merge Club Log's list into the master list
}
// NewManager loads any on-disk cache and returns a ready manager.
func NewManager(dataDir string) *Manager {
m := &Manager{
dir: dataDir,
client: &http.Client{Timeout: 60 * time.Second},
}
m.loadCache()
return m
}
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
func (m *Manager) clublogPath() string { return filepath.Join(m.dir, clublogCacheFile) }
// SetClublogEnabled turns the Club Log source on/off and reparses the on-disk
// caches so the in-memory list reflects the choice immediately.
func (m *Manager) SetClublogEnabled(on bool) {
if m.clublog.Swap(on) != on {
m.loadCache()
}
}
// ClublogEnabled reports whether the Club Log source is merged in.
func (m *Manager) ClublogEnabled() bool { return m.clublog.Load() }
func (m *Manager) loadCache() {
data, _ := os.ReadFile(m.path())
var extra []byte
if m.clublog.Load() {
extra, _ = os.ReadFile(m.clublogPath())
}
if data == nil && extra == nil {
return
}
m.parse(data, extra)
if fi, e := os.Stat(m.path()); e == nil {
m.mu.Lock()
m.updated = fi.ModTime()
m.mu.Unlock()
}
}
// Download fetches the latest MASTER.SCP, caches it and replaces the in-memory
// list. Returns the number of callsigns loaded.
func (m *Manager) Download(ctx context.Context) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return 0, fmt.Errorf("scp: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("scp: http %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return 0, fmt.Errorf("scp: read: %w", err)
}
var extra []byte
if m.clublog.Load() {
if cl, cerr := m.downloadClublog(ctx); cerr == nil {
extra = cl
} else {
// The master list alone is still worth having; fall back to any
// cached Club Log list rather than dropping the source silently.
extra, _ = os.ReadFile(m.clublogPath())
}
}
n := m.parse(body, extra)
if n == 0 {
return 0, fmt.Errorf("scp: file parsed to 0 callsigns")
}
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
m.mu.Lock()
m.updated = time.Now()
m.mu.Unlock()
return n, nil
}
// downloadClublog fetches Club Log's gzipped SCP list and caches it decompressed.
func (m *Manager) downloadClublog(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, clublogURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
resp, err := m.client.Do(req)
if err != nil {
return nil, fmt.Errorf("scp: clublog request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("scp: clublog http %d", resp.StatusCode)
}
gz, err := gzip.NewReader(io.LimitReader(resp.Body, 32*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog gunzip: %w", err)
}
body, err := io.ReadAll(io.LimitReader(gz, 64*1024*1024))
if err != nil {
return nil, fmt.Errorf("scp: clublog read: %w", err)
}
_ = os.WriteFile(m.clublogPath(), body, 0o644)
return body, nil
}
// parse loads the SCP bytes into the sorted call slice and returns the count.
func (m *Manager) parse(datasets ...[]byte) int {
seen := make(map[string]struct{}, 1<<17)
for _, data := range datasets {
if len(data) == 0 {
continue
}
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := strings.ToUpper(strings.TrimSpace(sc.Text()))
if line == "" || strings.HasPrefix(line, "#") {
continue // blank / header comment
}
// A call token only (the files are one call per line, but guard
// against stray trailing fields).
if i := strings.IndexAny(line, " \t,;"); i >= 0 {
line = line[:i]
}
if !plausibleCall(line) {
continue
}
seen[line] = struct{}{}
}
}
if len(seen) == 0 {
return 0
}
calls := make([]string, 0, len(seen))
for c := range seen {
calls = append(calls, c)
}
sort.Strings(calls)
m.mu.Lock()
m.calls = calls
m.mu.Unlock()
return len(calls)
}
// plausibleCall keeps a token that looks like a callsign: length 312, at least
// one digit and one letter, only AZ/09//.
func plausibleCall(s string) bool {
if len(s) < 3 || len(s) > 12 {
return false
}
hasDigit, hasAlpha := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
hasDigit = true
case c >= 'A' && c <= 'Z':
hasAlpha = true
case c == '/':
default:
return false
}
}
return hasDigit && hasAlpha
}
// Lookup returns the Partial (substring) and N+1 (one-edit) suggestions for the
// typed fragment. limit caps EACH list. Partial needs ≥2 chars; N+1 needs ≥3
// (a plausible whole call) and is skipped otherwise.
func (m *Manager) Lookup(fragment string, limit int) Result {
q := strings.ToUpper(strings.TrimSpace(fragment))
if len(q) < 2 {
return Result{}
}
if limit <= 0 {
limit = 50
}
m.mu.RLock()
calls := m.calls
m.mu.RUnlock()
wantN1 := len(q) >= 3
type pm struct {
call string
prefix bool
}
var partial []pm
var nplus1 []string
for _, c := range calls {
if c == q {
partial = append(partial, pm{c, true}) // exact = strongest match
continue
}
if idx := strings.Index(c, q); idx >= 0 {
partial = append(partial, pm{c, idx == 0})
}
if wantN1 && len(nplus1) < limit*2 && editDistanceOne(q, c) {
nplus1 = append(nplus1, c)
}
}
// Prefix matches first, then the rest (both already alphabetical since `calls`
// is sorted and we scanned in order).
sort.SliceStable(partial, func(i, j int) bool {
if partial[i].prefix != partial[j].prefix {
return partial[i].prefix
}
return false
})
out := Result{Partial: []string{}, NPlus1: []string{}}
for _, p := range partial {
if len(out.Partial) >= limit {
break
}
out.Partial = append(out.Partial, p.call)
}
if len(nplus1) > limit {
nplus1 = nplus1[:limit]
}
out.NPlus1 = append(out.NPlus1, nplus1...)
return out
}
// editDistanceOne reports whether a and b are exactly one edit apart (one
// substitution, insertion or deletion) — never equal (distance 0 returns false).
func editDistanceOne(a, b string) bool {
la, lb := len(a), len(b)
if la == lb {
diff := 0
for i := 0; i < la; i++ {
if a[i] != b[i] {
diff++
if diff > 1 {
return false
}
}
}
return diff == 1
}
// Ensure a is the shorter one; lengths must differ by exactly 1.
if la > lb {
a, b = b, a
la, lb = lb, la
}
if lb-la != 1 {
return false
}
// b is a with one extra char: walk both, allowing a single skip in b.
i, j, skipped := 0, 0, false
for i < la && j < lb {
if a[i] == b[j] {
i++
j++
continue
}
if skipped {
return false
}
skipped = true
j++ // skip the extra char in the longer string
}
return true
}
// Count returns how many callsigns are loaded.
func (m *Manager) Count() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.calls)
}
// Updated returns when the list was last refreshed (zero if never).
func (m *Manager) Updated() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.updated
}