306 lines
8.4 KiB
Go
306 lines
8.4 KiB
Go
// 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], <lat/lon>, {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
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
primary := strings.TrimSpace(parts[7])
|
|
// cty.dat marks non-DXCC entities (WAE / contest-only zone splits such
|
|
// as Sicily *IT9 and African Italy *IG9) with a leading '*' on the
|
|
// primary prefix. Those report under their parent DXCC entity. True
|
|
// DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no
|
|
// '*' and keep their own name. Per-prefix zones/lat-lon are preserved,
|
|
// so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy".
|
|
if strings.HasPrefix(primary, "*") {
|
|
primary = strings.TrimPrefix(primary, "*")
|
|
name = CanonicalEntityName(name)
|
|
}
|
|
e := &Entity{
|
|
Name: name,
|
|
Continent: strings.TrimSpace(parts[3]),
|
|
Primary: primary,
|
|
}
|
|
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], <lat/lon>, {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]
|
|
}
|