// 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 // 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 }