From edda183c16b55573c9909c773bce6edb0ccde4e8 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 28 May 2026 23:23:22 +0200 Subject: [PATCH] fix: normalization of city name address --- app.go | 28 +++++- frontend/src/App.tsx | 6 +- frontend/src/components/SettingsModal.tsx | 96 ++++++++++++------- frontend/wailsjs/go/models.ts | 10 ++ .../db/migrations/0015_profile_my_zones.sql | 9 ++ internal/lookup/lookup.go | 40 ++++++++ internal/lookup/normalize_test.go | 21 ++++ internal/profile/profile.go | 56 +++++++++-- 8 files changed, 216 insertions(+), 50 deletions(-) create mode 100644 internal/db/migrations/0015_profile_my_zones.sql create mode 100644 internal/lookup/normalize_test.go diff --git a/app.go b/app.go index ec35dea..f8eb14f 100644 --- a/app.go +++ b/app.go @@ -788,10 +788,30 @@ func (a *App) applyStationDefaults(q *qso.QSO) { v := *p.TxPower q.TXPower = &v } - // Resolve my zones / lat / lon via cty.dat using the profile's - // callsign. The profile only stores the human-friendly fields - // (callsign, grid, country name); cty.dat fills the structured - // DXCC metadata that the ADIF spec wants for every QSO. + // Profile-stored MY_* DXCC metadata wins (the user can override the + // auto-filled values in Station Information). + if q.MyDXCC == nil && p.MyDXCC != nil { + v := *p.MyDXCC + q.MyDXCC = &v + } + if q.MyCQZone == nil && p.MyCQZone != nil { + v := *p.MyCQZone + q.MyCQZone = &v + } + if q.MyITUZone == nil && p.MyITUZone != nil { + v := *p.MyITUZone + q.MyITUZone = &v + } + if q.MyLat == nil && p.MyLat != nil { + v := *p.MyLat + q.MyLat = &v + } + if q.MyLon == nil && p.MyLon != nil { + v := *p.MyLon + q.MyLon = &v + } + // Resolve any still-missing my zones / lat / lon via cty.dat using the + // profile's callsign — the fallback when the profile didn't store them. if a.dxcc != nil && p.Callsign != "" { if m, ok := a.dxcc.Lookup(p.Callsign); ok && m.Entity != nil { if q.MyCQZone == nil && m.CQZone != 0 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 38958a1..c1d6595 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1288,14 +1288,14 @@ export default function App() {
@@ -1304,6 +1304,7 @@ export default function App() { {catState.split ? 'TX Freq (MHz)' : 'Freq (MHz)'} setFreqFocused(true)} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 19a74a4..b80d354 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -125,41 +125,6 @@ interface Props { // the callsign + grid in the Station Information form. Debounces the // backend resolver so we don't fire on every keystroke; refreshes when // inputs change. Empty card when no callsign yet. -function StationInfoComputedBadge({ callsign, grid }: { callsign: string; grid: string }) { - const [info, setInfo] = useState<{ - country: string; dxcc: number; cqz: number; ituz: number; lat: number; lon: number; - } | null>(null); - useEffect(() => { - const c = callsign.trim(); - if (!c) { setInfo(null); return; } - const t = window.setTimeout(async () => { - try { - const i = await ComputeStationInfo(c, grid.trim()); - setInfo(i as any); - } catch { setInfo(null); } - }, 200); - return () => window.clearTimeout(t); - }, [callsign, grid]); - if (!info || (!info.country && !info.cqz && !info.ituz)) { - return null; - } - return ( -
-
- Auto-filled on each QSO (MY_*) -
-
- {info.country && Country: {info.country}} - {info.dxcc > 0 && DXCC#: {info.dxcc}} - {info.cqz > 0 && CQ: {info.cqz}} - {info.ituz > 0 && ITU: {info.ituz}} - {info.lat !== 0 && Lat: {info.lat.toFixed(4)}} - {info.lon !== 0 && Lon: {info.lon.toFixed(4)}} -
-
- ); -} - /* ====== Tree definition ====== Section IDs are stable strings — adding new ones means adding a panel below. `disabled: true` greys them out and shows the "coming soon" placeholder. */ @@ -499,6 +464,36 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { })(); }, []); + // Auto-fill the active profile's MY_* DXCC metadata from the station + // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These + // are derived values, so they always recompute when the callsign or grid + // changes — the user can still edit a field, it just re-populates when the + // source changes. Debounced so we don't hammer cty.dat while typing. + useEffect(() => { + const call = (activeProfile?.callsign ?? '').trim(); + if (!call) return; + const grid = (activeProfile?.my_grid ?? '').trim(); + const t = window.setTimeout(async () => { + try { + const i: any = await ComputeStationInfo(call, grid); + setActiveProfile((p) => { + if (!p) return p; + const patch: any = {}; + if (i.country) patch.my_country = i.country; + if (i.dxcc) patch.my_dxcc = i.dxcc; + if (i.cqz) patch.my_cqz = i.cqz; + if (i.ituz) patch.my_ituz = i.ituz; + if (i.lat) patch.my_lat = i.lat; + if (i.lon) patch.my_lon = i.lon; + // Only re-render when a value actually changed (prevents loops). + const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]); + return changed ? { ...p, ...patch } : p; + }); + } catch { /* offline / unknown prefix — leave fields as-is */ } + }, 250); + return () => window.clearTimeout(t); + }, [activeProfile?.callsign, activeProfile?.my_grid]); + // ── Band selection helpers (dual-list shuttle) ────────────────────────── function addBand(tag: string) { const b = tag.trim().toLowerCase(); @@ -681,7 +676,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
Legal station owner — only differs at club stations or remote setups (ADIF STATION_OWNER).
-
+
+ Auto-filled from the callsign — editable (stamped as MY_* on each QSO) +
updateActive({ my_grid: e.target.value })} placeholder="JN18BU" /> @@ -690,6 +687,33 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) { updateActive({ my_country: e.target.value })} placeholder="France" />
+
+
+ + updateActive({ my_dxcc: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" /> +
+
+ + updateActive({ my_cqz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" /> +
+
+ + updateActive({ my_ituz: e.target.value === '' ? undefined : (parseInt(e.target.value, 10) || undefined) } as any)} placeholder="—" /> +
+
+ + updateActive({ my_lat: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" /> +
+
+ + updateActive({ my_lon: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) } as any)} placeholder="—" /> +
+
updateActive({ my_state: e.target.value })} /> diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 7d32caa..1890c26 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -741,6 +741,11 @@ export namespace profile { my_pota_ref: string; my_rig: string; my_antenna: string; + my_dxcc?: number; + my_cqz?: number; + my_ituz?: number; + my_lat?: number; + my_lon?: number; tx_pwr?: number; is_active: boolean; sort_order: number; @@ -771,6 +776,11 @@ export namespace profile { this.my_pota_ref = source["my_pota_ref"]; this.my_rig = source["my_rig"]; this.my_antenna = source["my_antenna"]; + this.my_dxcc = source["my_dxcc"]; + this.my_cqz = source["my_cqz"]; + this.my_ituz = source["my_ituz"]; + this.my_lat = source["my_lat"]; + this.my_lon = source["my_lon"]; this.tx_pwr = source["tx_pwr"]; this.is_active = source["is_active"]; this.sort_order = source["sort_order"]; diff --git a/internal/db/migrations/0015_profile_my_zones.sql b/internal/db/migrations/0015_profile_my_zones.sql new file mode 100644 index 0000000..f2b88f6 --- /dev/null +++ b/internal/db/migrations/0015_profile_my_zones.sql @@ -0,0 +1,9 @@ +-- Per-profile MY_* DXCC metadata. These were previously derived from the +-- station callsign via cty.dat on every QSO; storing them on the profile +-- lets the user see and override them in Station Information (auto-filled +-- from the callsign, but editable for club/special situations). +ALTER TABLE station_profiles ADD COLUMN my_dxcc INTEGER; +ALTER TABLE station_profiles ADD COLUMN my_cqz INTEGER; +ALTER TABLE station_profiles ADD COLUMN my_ituz INTEGER; +ALTER TABLE station_profiles ADD COLUMN my_lat REAL; +ALTER TABLE station_profiles ADD COLUMN my_lon REAL; diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index ec821e7..e0a22fd 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "time" + "unicode" ) // ErrNotFound is returned by providers when a callsign is unknown. @@ -100,6 +101,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { // corrected entity mapping (e.g. Sicily → Italy) heal stale cached // rows without waiting for the TTL to expire. fillFromDXCC(&r, dxcc) + normalizeNames(&r) return r, nil } @@ -111,6 +113,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { r.Source = p.Name() r.FetchedAt = time.Now().UTC() fillFromDXCC(&r, dxcc) + normalizeNames(&r) _ = m.cache.Put(ctx, r) return r, nil } @@ -145,6 +148,43 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { return Result{}, lastErr } +// normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH +// reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown +// consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are +// left untouched (codes like CT must stay as-is). +func normalizeNames(r *Result) { + r.Name = titleCase(r.Name) + r.QTH = titleCase(r.QTH) + r.Address = titleCase(r.Address) +} + +// titleCase lowercases the whole string then capitalises the first letter of +// each word. Word boundaries are any non-alphanumeric rune (space, hyphen, +// apostrophe, slash…), so "vetraz-monthoux" → "Vetraz-Monthoux" and +// "o'brien" → "O'Brien". Digits never get a leading capital ("74140" stays). +func titleCase(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + runes := []rune(strings.ToLower(s)) + atWordStart := true + for i, r := range runes { + switch { + case unicode.IsLetter(r): + if atWordStart { + runes[i] = unicode.ToUpper(r) + } + atWordStart = false + case unicode.IsDigit(r): + atWordStart = false + default: + atWordStart = true + } + } + return string(runes) +} + // fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from // the cty.dat resolver. cty.dat is the authoritative source for DXCC // mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it diff --git a/internal/lookup/normalize_test.go b/internal/lookup/normalize_test.go new file mode 100644 index 0000000..3b7d8a3 --- /dev/null +++ b/internal/lookup/normalize_test.go @@ -0,0 +1,21 @@ +package lookup + +import "testing" + +func TestTitleCase(t *testing.T) { + cases := map[string]string{ + "NOEL CHENAVARD": "Noel Chenavard", + "VETRAZ-MONTHOUX": "Vetraz-Monthoux", + "o'brien": "O'Brien", + "866 ROUTE DES VOIRONS": "866 Route Des Voirons", + "PARIS": "Paris", + "": "", + " saint-étienne ": "Saint-Étienne", + "JOHN": "John", + } + for in, want := range cases { + if got := titleCase(in); got != want { + t.Errorf("titleCase(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go index d14be2a..e72365c 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -33,6 +33,11 @@ type Profile struct { MyPOTARef string `json:"my_pota_ref"` MyRig string `json:"my_rig"` MyAntenna string `json:"my_antenna"` + MyDXCC *int `json:"my_dxcc,omitempty"` + MyCQZone *int `json:"my_cqz,omitempty"` + MyITUZone *int `json:"my_ituz,omitempty"` + MyLat *float64 `json:"my_lat,omitempty"` + MyLon *float64 `json:"my_lon,omitempty"` TxPower *float64 `json:"tx_pwr,omitempty"` IsActive bool `json:"is_active"` SortOrder int `json:"sort_order"` @@ -48,7 +53,8 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, - my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at` + my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr, + is_active, sort_order, created_at, updated_at` // List returns every profile, active first then by sort_order/id. func (r *Repo) List(ctx context.Context) ([]Profile, error) { @@ -96,11 +102,14 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error { INSERT INTO station_profiles (name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty, my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref, - my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at) - VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`, + my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr, + is_active, sort_order, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?,?, ?,?,?,?)`, p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef, - p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now) + p.MyRig, p.MyAntenna, nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone), + nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower), + boolInt(p.IsActive), p.SortOrder, now, now) if err != nil { return err } @@ -112,12 +121,15 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error { UPDATE station_profiles SET name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?, my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?, - my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?, + my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, + my_dxcc = ?, my_cqz = ?, my_ituz = ?, my_lat = ?, my_lon = ?, tx_pwr = ?, sort_order = ?, updated_at = ? WHERE id = ?`, p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode, - p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), + p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, + nullableInt(p.MyDXCC), nullableInt(p.MyCQZone), nullableInt(p.MyITUZone), + nullableFloat(p.MyLat), nullableFloat(p.MyLon), nullableFloat(p.TxPower), p.SortOrder, now, p.ID) return err } @@ -210,13 +222,15 @@ func scan(row scannable) (Profile, error) { callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty, myStreet, myCity, myPostal, mySOTA, myPOTA, myRig, myAntenna sql.NullString - txPwr sql.NullFloat64 + myDXCC, myCQZ, myITUZ sql.NullInt64 + myLat, myLon, txPwr sql.NullFloat64 isActive, sortOrder int createdAt, updatedAt string ) err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty, &myStreet, &myCity, &myPostal, &mySOTA, &myPOTA, - &myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt) + &myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr, + &isActive, &sortOrder, &createdAt, &updatedAt) if err != nil { return p, err } @@ -234,6 +248,26 @@ func scan(row scannable) (Profile, error) { p.MyPOTARef = myPOTA.String p.MyRig = myRig.String p.MyAntenna = myAntenna.String + if myDXCC.Valid { + v := int(myDXCC.Int64) + p.MyDXCC = &v + } + if myCQZ.Valid { + v := int(myCQZ.Int64) + p.MyCQZone = &v + } + if myITUZ.Valid { + v := int(myITUZ.Int64) + p.MyITUZone = &v + } + if myLat.Valid { + v := myLat.Float64 + p.MyLat = &v + } + if myLon.Valid { + v := myLon.Float64 + p.MyLon = &v + } if txPwr.Valid { v := txPwr.Float64 p.TxPower = &v @@ -251,6 +285,12 @@ func nullableFloat(p *float64) any { } return *p } +func nullableInt(p *int) any { + if p == nil { + return nil + } + return *p +} func boolInt(b bool) int { if b { return 1