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", }