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.
339 lines
9.1 KiB
Go
339 lines
9.1 KiB
Go
// 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 3–12, at least
|
||
// one digit and one letter, only A–Z/0–9//.
|
||
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
|
||
}
|