// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU // zones, continent) by longest-prefix-matching against cty.dat — the // canonical prefix database maintained by AD1C at country-files.com, // the same file that every contest / logger consumes. // // The parser is line-oriented and tolerant: it handles cty.dat's // per-prefix overrides ((CQ), [ITU], , {Cont}) and the // "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM, // /5, …) are stripped before matching. package dxcc import ( "bufio" "io" "sort" "strconv" "strings" "sync" ) // Entity is one DXCC entity entry from cty.dat. type Entity struct { Name string `json:"name"` Continent string `json:"continent"` CQZone int `json:"cqz"` ITUZone int `json:"ituz"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` TZOffset float64 `json:"tz_offset"` Primary string `json:"primary_prefix"` // canonical short prefix (F, DL, K, …) } // Match is the resolved DXCC info for a callsign. Per-prefix overrides // from cty.dat are baked in; the Entity pointer is the unmodified parent. type Match struct { Entity *Entity `json:"entity"` Prefix string `json:"matched_prefix"` CQZone int `json:"cqz"` ITUZone int `json:"ituz"` Continent string `json:"continent"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` } type prefixEntry struct { prefix string entity *Entity cqOverride int ituOverride int contOverride string latOverride float64 lonOverride float64 hasLatLon bool } // DB is a parsed cty.dat ready for lookups. type DB struct { mu sync.RWMutex entities []*Entity exact map[string]prefixEntry // "=CALLSIGN" entries byPrefix []prefixEntry // sorted longest first } // Load parses a cty.dat stream. Safe to call once at startup. func Load(r io.Reader) (*DB, error) { db := &DB{exact: make(map[string]prefixEntry)} sc := bufio.NewScanner(r) // cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine // but we bump it to be safe. sc.Buffer(make([]byte, 64*1024), 1024*1024) var current *Entity var buf strings.Builder for sc.Scan() { line := strings.TrimRight(sc.Text(), "\r") if line == "" { continue } // Entity header lines start at column 0; continuation lines are // indented (cty.dat uses 4 spaces). if line[0] != ' ' && line[0] != '\t' { if e := parseEntityHeader(line); e != nil { db.entities = append(db.entities, e) current = e buf.Reset() } continue } if current == nil { continue } buf.WriteString(strings.TrimSpace(line)) // An entity's prefix list ends with ';' — possibly on a later line. if strings.HasSuffix(strings.TrimSpace(line), ";") { text := strings.TrimSuffix(buf.String(), ";") for _, raw := range strings.Split(text, ",") { raw = strings.TrimSpace(raw) if raw == "" { continue } entry, exact := parsePrefix(raw, current) if exact { db.exact[entry.prefix] = entry } else { db.byPrefix = append(db.byPrefix, entry) } } buf.Reset() } } if err := sc.Err(); err != nil { return nil, err } // Longest prefix first so HasPrefix wins on the most specific match. sort.Slice(db.byPrefix, func(i, j int) bool { return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix) }) return db, nil } // Entities returns the parsed entity list (read-only). func (db *DB) Entities() []*Entity { db.mu.RLock() defer db.mu.RUnlock() return db.entities } // Lookup resolves a callsign to its DXCC match using longest-prefix-match. // Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes // (DL/F4NIE → uses DL). Returns false if no prefix matches. func (db *DB) Lookup(callsign string) (Match, bool) { db.mu.RLock() defer db.mu.RUnlock() call := normalizeCallsign(callsign) if call == "" { return Match{}, false } if e, ok := db.exact[call]; ok { return materialize(e), true } for _, p := range db.byPrefix { if strings.HasPrefix(call, p.prefix) { return materialize(p), true } } return Match{}, false } func materialize(e prefixEntry) Match { m := Match{ Entity: e.entity, Prefix: e.prefix, CQZone: e.entity.CQZone, ITUZone: e.entity.ITUZone, Continent: e.entity.Continent, Lat: e.entity.Lat, Lon: e.entity.Lon, } if e.cqOverride != 0 { m.CQZone = e.cqOverride } if e.ituOverride != 0 { m.ITUZone = e.ituOverride } if e.contOverride != "" { m.Continent = e.contOverride } if e.hasLatLon { m.Lat = e.latOverride m.Lon = e.lonOverride } return m } // parseEntityHeader parses the colon-separated entity line: // // "France: 14: 27: EU: 46.00: -2.00: -1.0: F:" func parseEntityHeader(line string) *Entity { parts := strings.Split(line, ":") if len(parts) < 8 { return nil } e := &Entity{ Name: strings.TrimSpace(parts[0]), Continent: strings.TrimSpace(parts[3]), Primary: strings.TrimSpace(parts[7]), } e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1])) e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2])) e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64) e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64) e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64) if e.Name == "" { return nil } return e } // parsePrefix peels off cty.dat per-prefix annotations: // // K (5)[7]<35.50/-95.00>{NA}~America/Chicago~ // =W1AW func parsePrefix(s string, e *Entity) (prefixEntry, bool) { out := prefixEntry{entity: e} exact := false if strings.HasPrefix(s, "=") { exact = true s = s[1:] } // Strip annotations. Order them roughly so we extract before they appear // in the prefix slice. s = stripAnnotation(s, '(', ')', func(v string) { out.cqOverride, _ = strconv.Atoi(v) }) s = stripAnnotation(s, '[', ']', func(v string) { out.ituOverride, _ = strconv.Atoi(v) }) s = stripAnnotation(s, '<', '>', func(v string) { if a, b, ok := strings.Cut(v, "/"); ok { lat, e1 := strconv.ParseFloat(a, 64) lon, e2 := strconv.ParseFloat(b, 64) if e1 == nil && e2 == nil { out.latOverride, out.lonOverride = lat, lon out.hasLatLon = true } } }) s = stripAnnotation(s, '{', '}', func(v string) { out.contOverride = strings.TrimSpace(v) }) s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ }) out.prefix = strings.ToUpper(strings.TrimSpace(s)) return out, exact } // stripAnnotation removes a single ...X...Y... block and invokes cb with the // inner text. Used for (CQ), [ITU], , {cont}, ~tz~ annotations. func stripAnnotation(s string, open, close rune, cb func(string)) string { i := strings.IndexRune(s, open) if i < 0 { return s } j := strings.IndexRune(s[i+1:], close) if j < 0 { return s } cb(s[i+1 : i+1+j]) return s[:i] + s[i+1+j+1:] } // suffixModifiers are non-DXCC-relevant callsign suffixes we strip before // matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all // keep the operator's home DXCC. var suffixModifiers = map[string]bool{ "P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true, "PM": true, "LH": true, } // normalizeCallsign uppercases, trims, and resolves the "active" call when // the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). func normalizeCallsign(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if !strings.ContainsRune(s, '/') { return s } parts := strings.Split(s, "/") keep := parts[:0] for _, p := range parts { if p == "" { continue } if suffixModifiers[p] { continue } if len(p) == 1 && p >= "0" && p <= "9" { continue } keep = append(keep, p) } switch len(keep) { case 0: return s case 1: return keep[0] } // Two non-modifier parts → operating-from prefix wins (shorter one). // DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6. if len(keep[0]) <= len(keep[1]) { return keep[0] } return keep[1] }