fix: normalization of city name address
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user