feat: added record qso dvk

This commit is contained in:
2026-06-04 00:46:35 +02:00
parent 1a425a1b0d
commit a2a29c66d2
24 changed files with 3098 additions and 346 deletions
+173
View File
@@ -0,0 +1,173 @@
// Package clublog uses the ClubLog Country File (cty.xml) to resolve callsign
// EXCEPTIONS — date-ranged full-callsign overrides for DXpeditions and special
// operations that prefix-based files (cty.dat) get wrong. e.g. VK2/SP9FIH was
// Lord Howe Island (not Australia) between specific 2025 dates.
package clublog
import (
"compress/gzip"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
// Exception is one date-ranged full-callsign override.
type Exception struct {
Call string
Entity string
ADIF int
CQZ int
Cont string
Lat float64
Lon float64
Start time.Time // zero = no lower bound
End time.Time // zero = no upper bound
}
func (e Exception) covers(t time.Time) bool {
if !e.Start.IsZero() && t.Before(e.Start) {
return false
}
if !e.End.IsZero() && t.After(e.End) {
return false
}
return true
}
// DB holds the parsed exception list, keyed by upper-cased callsign.
type DB struct {
exceptions map[string][]Exception
date string // cty.xml generation date (for the UI)
count int
}
// Count returns how many exceptions were loaded.
func (db *DB) Count() int { return db.count }
// Date returns the cty.xml generation timestamp.
func (db *DB) Date() string { return db.date }
// xml decode shapes.
type xlException struct {
Call string `xml:"call"`
Entity string `xml:"entity"`
ADIF int `xml:"adif"`
CQZ int `xml:"cqz"`
Cont string `xml:"cont"`
Long string `xml:"long"`
Lat string `xml:"lat"`
Start string `xml:"start"`
End string `xml:"end"`
}
// LoadGzip parses a gzipped ClubLog cty.xml stream.
func LoadGzip(r io.Reader) (*DB, error) {
zr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gunzip: %w", err)
}
defer zr.Close()
return Load(zr)
}
// Load parses a (plain) ClubLog cty.xml stream, extracting only the
// <exceptions> section via a streaming decoder (the file is ~10 MB).
func Load(r io.Reader) (*DB, error) {
db := &DB{exceptions: map[string][]Exception{}}
dec := xml.NewDecoder(r)
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("xml: %w", err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch se.Name.Local {
case "clublog":
for _, a := range se.Attr {
if a.Name.Local == "date" {
db.date = a.Value
}
}
case "exception":
var x xlException
if err := dec.DecodeElement(&x, &se); err != nil {
continue
}
call := strings.ToUpper(strings.TrimSpace(x.Call))
if call == "" || x.ADIF == 0 {
continue
}
e := Exception{
Call: call, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
Start: parseTime(x.Start), End: parseTime(x.End),
}
db.exceptions[call] = append(db.exceptions[call], e)
db.count++
}
}
return db, nil
}
// Resolve returns the exception for a callsign valid at the given date, if any.
// It tries the call as-is, then with a trailing "/x" affix stripped (so
// VK2/SP9FIH/P still matches the VK2/SP9FIH exception).
func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
if db == nil {
return Exception{}, false
}
c := strings.ToUpper(strings.TrimSpace(call))
for _, key := range candidates(c) {
for _, e := range db.exceptions[key] {
if e.covers(date) {
return e, true
}
}
}
return Exception{}, false
}
// candidates yields the call and a version with one trailing affix removed.
func candidates(c string) []string {
out := []string{c}
if i := strings.LastIndex(c, "/"); i > 0 {
suffix := c[i+1:]
// Only strip short operational affixes, not a real prefix override
// (e.g. keep "VK2/SP9FIH" intact; strip "VK2/SP9FIH/P").
switch suffix {
case "P", "M", "MM", "AM", "QRP", "A":
out = append(out, c[:i])
}
}
return out
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
return time.Time{}
}
func parseFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
var f float64
fmt.Sscanf(s, "%g", &f)
return f
}
+127
View File
@@ -0,0 +1,127 @@
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)
}