// 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) // FeedDate is the newest upload date anywhere in ARRL's file, i.e. when ARRL // last regenerated it, and FeedStale says that date is more than two days old. // Without them "last uploaded 7 days ago" is unreadable: it may mean the // station went quiet, or that the FEED has been frozen for a week — which is // exactly what ARRL served on 2026-08-02, its newest line dated 2026-07-27. FeedDate string `json:"feed_date,omitempty"` FeedStale bool `json:"feed_stale"` } // 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 newest time.Time // newest upload date in the file = when ARRL built it 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) var newest time.Time 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 t.After(newest) { newest = t } } if len(users) == 0 { return 0 } m.mu.Lock() m.users = users m.newest = newest m.mu.Unlock() return len(users) } // feedInfo fills the feed-age fields shared by every Lookup result. func (m *Manager) feedInfo(i Info) Info { if !m.newest.IsZero() { i.FeedDate = m.newest.Format("2006-01-02") i.FeedStale = time.Since(m.newest) > 48*time.Hour } return i } // 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 m.feedInfo(Info{DaysAgo: -1}) } days := int(time.Since(t).Hours() / 24) if days < 0 { days = 0 } return m.feedInfo(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 }