// 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 + prefix tables. Exceptions are keyed by the // full callsign; prefixes are keyed by the prefix string (both may hold several // date-ranged entries). cty.xml carries entity + CQ zone + continent per // record, but NOT ITU zone. type DB struct { exceptions map[string][]Exception prefixes map[string][]Exception date string // cty.xml generation date (for the UI) count int prefixCount int } // Count returns how many exceptions were loaded. func (db *DB) Count() int { return db.count } // PrefixCount returns how many prefix records were loaded. func (db *DB) PrefixCount() int { return db.prefixCount } // 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{}, prefixes: 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++ case "prefix": var x xlException // same shape; holds the prefix string if err := dec.DecodeElement(&x, &se); err != nil { continue } pfx := strings.ToUpper(strings.TrimSpace(x.Call)) if pfx == "" || x.ADIF == 0 { continue } e := Exception{ Call: pfx, 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.prefixes[pfx] = append(db.prefixes[pfx], e) db.prefixCount++ } } return db, nil } // ResolvePrefix returns the longest prefix entry matching a callsign and valid // at the given date (cascade step 2). The callsign should already be normalized // (operating affixes stripped); we still strip a trailing "/x" defensively. func (db *DB) ResolvePrefix(call string, date time.Time) (Exception, bool) { if db == nil { return Exception{}, false } c := strings.ToUpper(strings.TrimSpace(call)) if i := strings.LastIndex(c, "/"); i > 0 { // Prefer the operating prefix when present (MM/DL1ABC → MM). if pre, post := c[:i], c[i+1:]; len(pre) <= len(post) { c = pre } } for n := len(c); n >= 1; n-- { for _, e := range db.prefixes[c[:n]] { if e.covers(date) { return e, true } } } return Exception{}, false } // ResolveFull runs the ClubLog cascade: a full-callsign Exception first, then // the longest valid Prefix. Returns the matched record and its source // ("exception" | "prefix"), or ok=false when ClubLog has nothing (caller falls // back to cty.dat). func (db *DB) ResolveFull(call string, date time.Time) (e Exception, source string, ok bool) { if db == nil { return Exception{}, "", false } if e, ok := db.Resolve(call, date); ok { return e, "exception", true } if e, ok := db.ResolvePrefix(call, date); ok { return e, "prefix", true } return Exception{}, "", false } // 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 }