// 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" "context" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strings" "sync" "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" const cacheFile = "MASTER.SCP" // 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 } // 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) loadCache() { data, err := os.ReadFile(m.path()) if err != nil { return } m.parse(data) 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) } n := m.parse(body) 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 } // parse loads the SCP bytes into the sorted call slice and returns the count. func (m *Manager) parse(data []byte) int { seen := make(map[string]struct{}, 1<<17) 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 master file is 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 }