This commit is contained in:
2026-06-06 01:21:24 +02:00
parent 922a185208
commit b4e104f5a2
9 changed files with 381 additions and 42 deletions
+70 -10
View File
@@ -94,7 +94,8 @@ const (
keyAwardDefs = "awards.defs" // JSON array of award definitions (editable)
keyAwardRefsUpdated = "awards.refs.updated." // + CODE → last list-update timestamp
keyAwardRefsSeeded = "awards.refs.seeded" // "1" once built-in lists were seeded
keyAwardRefsSeeded = "awards.refs.seeded" // built-in reference-list seed version
keyAwardDefsFixed = "awards.defs.fixed" // built-in award def correction version
keyClublogCtyEnabled = "clublog.cty_exceptions" // "1" → apply ClubLog exceptions
@@ -1072,6 +1073,7 @@ func (a *App) AddQSO(q qso.QSO) (int64, error) {
a.applyStationDefaults(&q)
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
// Fill the contacted operator's e-mail from the (cached) lookup so the
// recording can be auto-sent. Cheap: the entry already looked the call up.
@@ -1141,6 +1143,11 @@ func (a *App) ComputeStationInfo(callsign, grid string) StationInfoComputed {
out.Lat = m.Lat
out.Lon = m.Lon
out.DXCC = dxcc.EntityDXCC(m.Entity.Name)
// Refine zones by call district (W6 → CQ3/ITU6) so the entry strip
// shows what will be logged.
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(out.DXCC, callsign); ok {
out.CQZ, out.ITUZ = cqz, ituz
}
}
}
// Grid wins on lat/lon — it's user-set, finer than the DXCC centroid.
@@ -1167,6 +1174,20 @@ func (a *App) applyDXCCNumber(q *qso.QSO) {
}
}
// refineDistrictZones sets the CQ/ITU zone from the call district for
// zone-split countries (USA, Australia), so every entry point — manual,
// UDP, import, re-stamp, award scan — agrees on W6 = CQ3/ITU6 instead of the
// coarse per-entity default. Call AFTER the DXCC is finalised.
func (a *App) refineDistrictZones(q *qso.QSO) {
if q.DXCC == nil {
return
}
if cqz, ituz, ok := dxcc.ZoneByCallDistrict(*q.DXCC, q.Callsign); ok {
zc, zi := cqz, ituz
q.CQZ, q.ITUZ = &zc, &zi
}
}
// applyStationDefaults fills any empty MY_* / station field on q with the
// currently-active profile's values. Multi-profile support means a user
// can be /P with a different callsign + grid + SOTA ref than home — the
@@ -1354,12 +1375,31 @@ func (a *App) migrateAwardDefs() {
return
}
migrated, changed := award.Migrate(defs)
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
// from Defaults to protected/built-in awards once.
const defsFixVersion = "2"
if v, _ := a.settings.Get(a.ctx, keyAwardDefsFixed); v != defsFixVersion {
byCode := map[string]award.Def{}
for _, d := range award.Defaults() {
byCode[strings.ToUpper(d.Code)] = d
}
for i := range migrated {
if d, ok := byCode[strings.ToUpper(migrated[i].Code)]; ok && (migrated[i].Builtin || migrated[i].Protected) {
migrated[i].Confirm = d.Confirm
migrated[i].Validate = d.Validate
changed = true
}
}
_ = a.settings.Set(a.ctx, keyAwardDefsFixed, defsFixVersion)
}
if !changed {
return
}
if b, err := json.Marshal(migrated); err == nil {
_ = a.settings.Set(a.ctx, keyAwardDefs, string(b))
applog.Printf("awards: migrated %d legacy definitions to the new model", len(migrated))
applog.Printf("awards: migrated/fixed %d definitions", len(migrated))
}
}
@@ -1716,6 +1756,10 @@ func (a *App) enrichQSOForAwards(q *qso.QSO) {
q.DXCC = &n
}
}
// Zone-split countries (USA, Australia): the per-entity default zone is too
// coarse (W6 = CQ5 instead of 3). Apply the call-district rule so awards
// (WAZ / WITUZ) match Log4OM. This OVERRIDES a stored entity-default zone.
a.refineDistrictZones(q)
}
// awardBandPlan maps a frequency (Hz) to its ADIF band. Used to recover the
@@ -1932,25 +1976,37 @@ func (a *App) HasBuiltinReferences(code string) bool {
return ok
}
// seedBuiltinReferences populates the reference lists of built-in awards on
// first run (idempotent: only seeds an award that currently has none, and only
// once overall, tracked by a settings flag so a user who clears a list is not
// overruled on the next launch).
// builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "2"
// seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored): seed only awards that have NO references
// yet, so an online-loaded list (POTA…) or a user list isn't clobbered.
// - Version bump (stored != current): RE-SEED the derived built-in lists
// (DXCC, WAZ, WAC, WAS, DDFM) to push data corrections to existing installs.
// These lists are canonical, not user-maintained, so overwriting is safe.
func (a *App) seedBuiltinReferences() {
if a.awardRefs == nil || a.settings == nil {
return
}
if done, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded); done == "1" {
ver, _ := a.settings.Get(a.ctx, keyAwardRefsSeeded)
if ver == builtinRefsVersion {
return
}
firstRun := ver == "" || ver == "1" // "1" was the old boolean flag
if ver == "1" {
firstRun = false // already seeded once → treat as a version upgrade
}
counts, err := a.awardRefs.Counts(a.ctx)
if err != nil {
return
}
for _, d := range a.awardDefs() {
code := strings.ToUpper(d.Code)
if counts[code] > 0 {
continue
if firstRun && counts[code] > 0 {
continue // don't overwrite an existing list on a fresh install
}
if refs, ok := awardref.BuiltinRefs(code); ok {
if n, err := a.awardRefs.ReplaceAll(a.ctx, code, refs); err == nil {
@@ -1958,7 +2014,7 @@ func (a *App) seedBuiltinReferences() {
}
}
}
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, "1")
_ = a.settings.Set(a.ctx, keyAwardRefsSeeded, builtinRefsVersion)
}
// ImportAwardReferencesText parses pasted lines or CSV into references and
@@ -4100,6 +4156,9 @@ func (a *App) enrichContactedFromCtyForce(q *qso.QSO) bool {
v := m.ITUZone
q.ITUZ = &v
}
// Zone-split countries (USA, Australia): refine the per-entity default zone
// to the call-district zone (W6 → CQ3/ITU6), matching Log4OM/DXKeeper.
a.refineDistrictZones(q)
return true
}
@@ -4469,6 +4528,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
// fields (or what the lookup gave us) always win.
a.applyDXCCNumber(&q)
a.applyClublogException(&q, false) // date-ranged DXpedition override
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
// ── Dedup ──
+14 -3
View File
@@ -436,8 +436,18 @@ export default function App() {
return () => window.clearTimeout(t);
}, [error]);
// True while the QSO recorder is capturing the current contact (set when we
// leave the callsign field, cleared on log/cancel). Drives the "REC" badge.
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
const [recording, setRecording] = useState(false);
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress.
const [recSeconds, setRecSeconds] = useState(0);
useEffect(() => {
if (!recording) { setRecSeconds(0); return; }
const start = Date.now();
setRecSeconds(0);
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
return () => window.clearInterval(id);
}, [recording]);
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
@@ -1564,8 +1574,9 @@ export default function App() {
</Label>
<div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[9px] font-semibold tracking-wider text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />REC
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none">
<span className="size-2 rounded-full bg-red-600 animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span>
)}
<Input
+15 -10
View File
@@ -86,18 +86,23 @@ type Def struct {
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
// accepts it (WAC).
lq := []string{"lotw", "qsl"}
lo := []string{"lotw"}
return []Def{
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lq, Total: 340, Valid: true, Builtin: true, Protected: true},
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lq, Total: 50, Valid: true, Builtin: true, Protected: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lq, Total: 40, Valid: true, Builtin: true, Protected: true},
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: []string{"lotw", "qsl", "eqsl"}, Total: 6, Valid: true, Builtin: true, Protected: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lq, Total: 96, Valid: true, Builtin: true, Protected: true},
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: []string{"qsl"}, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lq, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
}
}
+5
View File
@@ -51,6 +51,11 @@ func TestComputeDXCCAndConfirm(t *testing.T) {
if dxcc.Confirmed != 3 {
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
}
// Validated is the stricter LoTW-only tier: a paper QSL confirms but does
// NOT validate, so only USA (LoTW) counts.
if dxcc.Validated != 1 {
t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated)
}
was := by["WAS"]
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
+68 -2
View File
@@ -36,16 +36,24 @@ func (e Exception) covers(t time.Time) bool {
return true
}
// DB holds the parsed exception list, keyed by upper-cased callsign.
// 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 }
@@ -75,7 +83,7 @@ func LoadGzip(r io.Reader) (*DB, error) {
// 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{}}
db := &DB{exceptions: map[string][]Exception{}, prefixes: map[string][]Exception{}}
dec := xml.NewDecoder(r)
for {
tok, err := dec.Token()
@@ -113,11 +121,69 @@ func Load(r io.Reader) (*DB, error) {
}
db.exceptions[call] = append(db.exceptions[call], e)
db.count++
case "prefix":
var x xlException // same shape; <call> 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).
+54
View File
@@ -58,6 +58,60 @@ func NameForDXCC(n int) string {
return strings.Title(name) //nolint:staticcheck // ASCII entity names
}
// ZoneByCallDistrict returns the CQ and ITU zone for a callsign in a country
// that is split across zones by call district (USA, Australia…). cty.dat and
// ClubLog's cty.xml only carry one zone per entity, so loggers apply this
// district→zone convention to get e.g. W6 = CQ3/ITU6 instead of the entity
// default CQ5/ITU8. ok=false means no district rule applies (use the entity
// default). The district is the first digit of the callsign.
func ZoneByCallDistrict(adif int, call string) (cqz, ituz int, ok bool) {
d := districtDigit(call)
if d < 0 {
return 0, 0, false
}
switch adif {
case 291: // United States — standard district defaults (state-level
// exceptions exist, but this matches what Log4OM/DXKeeper default to).
if z, o := usDistrictZones[d]; o {
return z[0], z[1], true
}
case 150: // Australia — VK6/VK8 (west/north) are CQ29/ITU58, rest CQ30/ITU59.
if d == 6 || d == 8 {
return 29, 58, true
}
return 30, 59, true
}
return 0, 0, false
}
// usDistrictZones maps a US call district digit to its {CQ, ITU} zone.
var usDistrictZones = map[int][2]int{
0: {4, 7}, 1: {5, 8}, 2: {5, 8}, 3: {5, 8}, 4: {5, 8},
5: {4, 7}, 6: {3, 6}, 7: {3, 6}, 8: {5, 8}, 9: {4, 8},
}
// firstDigit returns the first 0-9 digit in a callsign, or -1 if none.
func firstDigit(call string) int {
for i := 0; i < len(call); i++ {
if call[i] >= '0' && call[i] <= '9' {
return int(call[i] - '0')
}
}
return -1
}
// districtDigit returns the effective call-area digit: a trailing "/N" (single
// digit) re-homes the call to area N (W6ABC/7 → area 7), otherwise the first
// digit of the call.
func districtDigit(call string) int {
if i := strings.LastIndex(call, "/"); i >= 0 && i == len(call)-2 {
if c := call[len(call)-1]; c >= '0' && c <= '9' {
return int(c - '0')
}
}
return firstDigit(call)
}
// EntityNumberName pairs a DXCC entity number with its display name.
type EntityNumberName struct {
Num int
+53 -10
View File
@@ -139,7 +139,15 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
if e, ok := db.exact[call]; ok {
return materialize(e), true
}
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
// cty.dat carries a bare "KG4" prefix for Guantanamo, so for the other
// suffix lengths we must skip it and fall through to the USA prefixes.
skipKG4 := strings.HasPrefix(call, "KG4") && len(call) != len("KG4")+2
for _, p := range db.byPrefix {
if skipKG4 && p.prefix == "KG4" {
continue
}
if strings.HasPrefix(call, p.prefix) {
return materialize(p), true
}
@@ -262,22 +270,33 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
}
// 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.
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
"P": true, "M": 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).
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
for i, p := range parts {
if i > 0 && (p == "MM" || p == "AM") {
return ""
}
}
keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for _, p := range parts {
if p == "" {
continue
@@ -285,21 +304,45 @@ func normalizeCallsign(s string) string {
if suffixModifiers[p] {
continue
}
if len(p) == 1 && p >= "0" && p <= "9" {
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
areaDigit = p[0]
continue
}
keep = append(keep, p)
}
var main string
switch len(keep) {
case 0:
return s
case 1:
return keep[0]
}
main = keep[0]
default:
// Two non-modifier parts → operating-from prefix wins (shorter one).
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 → W6.
if len(keep[0]) <= len(keep[1]) {
return keep[0]
main = keep[0]
} else {
main = keep[1]
}
return keep[1]
}
// Apply the call-area digit: "/N" replaces the area digit of the base call,
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
// Ecuador). This is the same class of rule as KG4 and /MM.
if areaDigit != 0 {
main = replaceFirstDigit(main, areaDigit)
}
return main
}
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
func replaceFirstDigit(call string, d byte) string {
b := []byte(call)
for i := range b {
if b[i] >= '0' && b[i] <= '9' {
b[i] = d
return string(b)
}
}
return call
}
+1 -1
View File
@@ -341,7 +341,7 @@ var dxccByName = map[string]int{
"wake island": 297,
"wales": 294,
"wallis & futuna islands": 298,
"west malaysia": 155,
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
"western kiribati": 301,
"western sahara": 302,
"willis island": 303,
+97 -2
View File
@@ -54,6 +54,96 @@ func TestLookup(t *testing.T) {
}
}
// A "/N" call-area suffix can change the DXCC entity: HD5MW/8 re-homes to the
// HD8 area (Galápagos), not the base call's Ecuador.
func TestCallAreaSuffix(t *testing.T) {
const cty = `Ecuador: 10: 12: SA: -1.40: 78.40: 5.0: HC:
HC,HD;
Galapagos Islands: 10: 12: SA: 0.00: 91.00: 6.0: HC8:
HC8,HD8;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"HD5MW": "Ecuador",
"HD5MW/8": "Galapagos Islands",
"HC2AO": "Ecuador",
"HD8M": "Galapagos Islands",
"HC1WW/8": "Galapagos Islands",
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// US/VK call-district zone refinement (W6 = CQ3/ITU6, not the entity default).
func TestZoneByCallDistrict(t *testing.T) {
cases := []struct {
adif int
call string
cqz, ituz int
ok bool
}{
{291, "W6XYZ", 3, 6, true},
{291, "K7AB", 3, 6, true},
{291, "W4ABC", 5, 8, true},
{291, "N0CALL", 4, 7, true},
{291, "AA5XX", 4, 7, true},
{150, "VK6AA", 29, 58, true}, // West Australia
{150, "VK3XY", 30, 59, true}, // Victoria
{230, "DL1ABC", 0, 0, false}, // Germany: no district rule
{291, "WABC", 0, 0, false}, // no digit
}
for _, c := range cases {
cqz, ituz, ok := ZoneByCallDistrict(c.adif, c.call)
if ok != c.ok || (ok && (cqz != c.cqz || ituz != c.ituz)) {
t.Errorf("ZoneByCallDistrict(%d,%q) = %d/%d ok=%v, want %d/%d ok=%v",
c.adif, c.call, cqz, ituz, ok, c.cqz, c.ituz, c.ok)
}
}
}
// KG4 is Guantanamo Bay only with a 2-character suffix (KG4XX); 1- or 3-char
// suffixes (KG4W, KG4ABC) are continental USA. cty.dat carries a bare "KG4"
// prefix, so the resolver must apply the suffix-length rule.
func TestKG4SuffixRule(t *testing.T) {
const cty = `United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,N,W;
Guantanamo Bay: 08: 11: NA: 19.92: 75.18: -5.0: KG4:
KG4;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"KG4W": "United States", // 1-char suffix
"KG4AA": "Guantanamo Bay", // 2-char suffix
"KG4ABC": "United States", // 3-char suffix
"KG4": "United States", // no suffix
"KG4W/P": "United States", // modifier stripped, still 1-char
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
// leading '*'; the parser must fold those into their parent DXCC entity
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
@@ -108,9 +198,14 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO",
" F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO",
"F4BPO/MM": "F4BPO",
"F4BPO/5": "F4BPO",
"F4BPO/MM": "", // maritime mobile → no DXCC entity
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
"DL/F4BPO": "DL",
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
"MM/LY3X/P": "MM",
"F4BPO/W6": "W6",
"VK9/F4BPO": "VK9",
}