128 lines
2.8 KiB
Go
128 lines
2.8 KiB
Go
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)
|
|
}
|