fix: Added LOTW badge for Lotw users colored depending on their last upload
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user