diff --git a/app.go b/app.go
index 73688b9..37416d5 100644
--- a/app.go
+++ b/app.go
@@ -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 ──
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 37b1497..1d88657 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
-
- REC
+
+
+ {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
)}
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; 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).
diff --git a/internal/dxcc/adif_numbers.go b/internal/dxcc/adif_numbers.go
index e8826f7..fda05d4 100644
--- a/internal/dxcc/adif_numbers.go
+++ b/internal/dxcc/adif_numbers.go
@@ -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
diff --git a/internal/dxcc/dxcc.go b/internal/dxcc/dxcc.go
index 0f52e4a..c0b2d11 100644
--- a/internal/dxcc/dxcc.go
+++ b/internal/dxcc/dxcc.go
@@ -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 → W6.
+ if len(keep[0]) <= len(keep[1]) {
+ main = keep[0]
+ } else {
+ main = keep[1]
+ }
}
- // 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]
+ // 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 keep[1]
+ 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
}
diff --git a/internal/dxcc/dxcc_names_gen.go b/internal/dxcc/dxcc_names_gen.go
index 7555b68..836174b 100644
--- a/internal/dxcc/dxcc_names_gen.go
+++ b/internal/dxcc/dxcc_names_gen.go
@@ -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,
diff --git a/internal/dxcc/dxcc_test.go b/internal/dxcc/dxcc_test.go
index 4f525b5..309c22c 100644
--- a/internal/dxcc/dxcc_test.go
+++ b/internal/dxcc/dxcc_test.go
@@ -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",
}