// Package lotwusers tracks which callsigns are LoTW users and when they last // uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a // colour-coded badge next to the entered callsign (recent upload = likely to // confirm on LoTW). The list is cached on disk so it survives restarts. package lotwusers import ( "bufio" "bytes" "context" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" ) // feedURL is ARRL's public LoTW last-upload activity list (CSV: // "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign). const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv" const cacheFile = "lotw-user-activity.csv" // Info is a lookup result for one callsign. type Info struct { IsUser bool `json:"is_user"` LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown) } // Manager holds the parsed list + cache location. type Manager struct { mu sync.RWMutex users map[string]time.Time // UPPER(callsign) → last-upload date (UTC) 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{ users: map[string]time.Time{}, dir: dataDir, client: &http.Client{Timeout: 90 * 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 list, caches it to disk and replaces the in-memory // map. Returns the number of callsigns loaded. func (m *Manager) Download(ctx context.Context) (int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, 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("lotwusers: request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024)) if err != nil { return 0, fmt.Errorf("lotwusers: read: %w", err) } n := m.parse(body) if n == 0 { return 0, fmt.Errorf("lotwusers: feed 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 CSV bytes into the map and returns the count. func (m *Manager) parse(data []byte) int { users := make(map[string]time.Time, 1<<20) sc := bufio.NewScanner(bytes.NewReader(data)) sc.Buffer(make([]byte, 1024*1024), 1024*1024) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) < 2 { continue } call := strings.ToUpper(strings.TrimSpace(parts[0])) if call == "" || strings.EqualFold(call, "callsign") { continue // header row } t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1])) if err != nil { continue } users[call] = t } if len(users) == 0 { return 0 } m.mu.Lock() m.users = users m.mu.Unlock() return len(users) } // Lookup reports whether callsign is a LoTW user and how long ago it uploaded. // Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P") // the longest slash-separated segment — the base call LoTW is keyed on. func (m *Manager) Lookup(callsign string) Info { c := strings.ToUpper(strings.TrimSpace(callsign)) if c == "" { return Info{DaysAgo: -1} } m.mu.RLock() defer m.mu.RUnlock() if len(m.users) == 0 { return Info{DaysAgo: -1} } t, ok := m.users[c] if !ok && strings.Contains(c, "/") { base := "" for _, seg := range strings.Split(c, "/") { if len(seg) > len(base) { base = seg } } t, ok = m.users[base] } if !ok { return Info{DaysAgo: -1} } days := int(time.Since(t).Hours() / 24) if days < 0 { days = 0 } return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days} } // Count returns how many callsigns are loaded. func (m *Manager) Count() int { m.mu.RLock() defer m.mu.RUnlock() return len(m.users) } // 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 }