package clublog import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "sync" "time" ) // ctyURL is the ClubLog Country File endpoint. It returns a gzipped cty.xml. const ctyURL = "https://cdn.clublog.org/cty.php?api=" // Manager owns the on-disk cty.xml.gz cache and the parsed exception DB. type Manager struct { apiKey string cacheDir string mu sync.RWMutex db *DB } func NewManager(apiKey, cacheDir string) *Manager { return &Manager{apiKey: apiKey, cacheDir: cacheDir} } // Path is where the cached gzipped country file lives. func (m *Manager) Path() string { return filepath.Join(m.cacheDir, "clublog_cty.xml.gz") } // Loaded reports whether an exception DB is in memory. func (m *Manager) Loaded() bool { m.mu.RLock() defer m.mu.RUnlock() return m.db != nil } // Info returns the loaded file's generation date + exception count (zeros when // not loaded). func (m *Manager) Info() (date string, count int) { m.mu.RLock() defer m.mu.RUnlock() if m.db == nil { return "", 0 } return m.db.Date(), m.db.Count() } // EnsureLoaded loads the cached file into memory if present. Does NOT download. func (m *Manager) EnsureLoaded() error { if m.Loaded() { return nil } return m.LoadFromDisk() } // LoadFromDisk parses the cached cty.xml.gz and swaps it in. func (m *Manager) LoadFromDisk() error { f, err := os.Open(m.Path()) if err != nil { return err } defer f.Close() db, err := LoadGzip(f) if err != nil { return err } m.mu.Lock() m.db = db m.mu.Unlock() return nil } // Download fetches a fresh cty.xml.gz from ClubLog and writes it atomically, // then loads it. func (m *Manager) Download(ctx context.Context) error { if m.apiKey == "" { return fmt.Errorf("clublog api key not set") } if err := os.MkdirAll(m.cacheDir, 0o755); err != nil { return err } req, err := http.NewRequestWithContext(ctx, "GET", ctyURL+m.apiKey, nil) if err != nil { return err } client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("clublog HTTP %d", resp.StatusCode) } tmp, err := os.CreateTemp(m.cacheDir, "clublog-*.tmp") if err != nil { return err } tmpName := tmp.Name() if _, err := io.Copy(tmp, resp.Body); err != nil { tmp.Close() os.Remove(tmpName) return err } tmp.Close() if err := os.Rename(tmpName, m.Path()); err != nil { os.Remove(tmpName) return err } return m.LoadFromDisk() } // Resolve returns the matching exception for a callsign at a date, if loaded. func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) { m.mu.RLock() db := m.db m.mu.RUnlock() if db == nil { return Exception{}, false } return db.Resolve(call, date) }