feat(lookup): optional QRZ nickname as the logged name

QRZ publishes <nickname> — the name an operator goes BY on the air — and
OpsLog was composing fname + name instead. "Bob" is what belongs in a log;
"Robert J Smith" is what belongs on a licence.

QRZ only, and deliberately so: HamQTH's <nick> already fills the Name field
that way, so the same switch there would toggle a behaviour it has no way to
turn off.

A published nickname is optional, so an empty one falls through to the
registered name. That is the whole point of it being a fallback rather than a
swap, and it is what the test pins — a blank nickname must never blank the
name.
This commit is contained in:
2026-08-13 10:49:52 +02:00
parent b2382a6135
commit 8e49d37cbd
9 changed files with 140 additions and 57 deletions
+18 -18
View File
@@ -21,29 +21,29 @@ var ErrNotFound = errors.New("callsign not found")
// Result is the normalized lookup output regardless of provider.
type Result struct {
Callsign string `json:"callsign"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
Country string `json:"country,omitempty"`
Grid string `json:"grid,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
DXCC int `json:"dxcc,omitempty"`
CQZ int `json:"cqz,omitempty"`
ITUZ int `json:"ituz,omitempty"`
Continent string `json:"cont,omitempty"`
Email string `json:"email,omitempty"`
QSLVia string `json:"qsl_via,omitempty"`
Callsign string `json:"callsign"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
Country string `json:"country,omitempty"`
Grid string `json:"grid,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
DXCC int `json:"dxcc,omitempty"`
CQZ int `json:"cqz,omitempty"`
ITUZ int `json:"ituz,omitempty"`
Continent string `json:"cont,omitempty"`
Email string `json:"email,omitempty"`
QSLVia string `json:"qsl_via,omitempty"`
// Web is the operator's own site. The QSO table has had a `web` column all
// along and nothing ever filled it, because no provider mapping read the
// field.
Web string `json:"web,omitempty"`
// Zip is the postal code. HamQTH and QRZ both send one.
Zip string `json:"zip,omitempty"`
ImageURL string `json:"image_url,omitempty"` // profile picture URL
Zip string `json:"zip,omitempty"`
ImageURL string `json:"image_url,omitempty"` // profile picture URL
Source string `json:"source"` // "qrz", "hamqth", or "cache"
FetchedAt time.Time `json:"fetched_at"`
}
+7 -7
View File
@@ -4,14 +4,14 @@ import "testing"
func TestTitleCase(t *testing.T) {
cases := map[string]string{
"NOEL CHENAVARD": "Noel Chenavard",
"VETRAZ-MONTHOUX": "Vetraz-Monthoux",
"o'brien": "O'Brien",
"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",
"PARIS": "Paris",
"": "",
" saint-étienne ": "Saint-Étienne",
"JOHN": "John",
}
for in, want := range cases {
if got := titleCase(in); got != want {
+24 -3
View File
@@ -21,8 +21,14 @@ type QRZ struct {
HTTP *http.Client
mu sync.Mutex
session string
// PreferNickname takes QRZ's <nickname> over the composed first+last name
// when the operator has published one. It is the name they go BY on the air,
// which is what belongs in a log — HamQTH's <nick> is already used that way,
// and this brings QRZ into line for operators who want it.
PreferNickname bool
mu sync.Mutex
session string
loggedAt time.Time
}
@@ -117,7 +123,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
}
r := Result{
Callsign: strings.ToUpper(c.Call),
Name: joinName(c.FName, c.Name),
Name: qrzName(q.PreferNickname, c.Nickname, c.FName, c.Name),
QTH: c.Addr2,
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
State: strings.ToUpper(c.State),
@@ -169,6 +175,7 @@ type qrzSession struct {
type qrzCallsign struct {
Call string `xml:"call"`
FName string `xml:"fname"`
Nickname string `xml:"nickname"` // the name the operator goes by on the air
Name string `xml:"name"`
Addr1 string `xml:"addr1"`
Addr2 string `xml:"addr2"`
@@ -235,3 +242,17 @@ func firstNonEmpty(s ...string) string {
}
return ""
}
// qrzName picks what goes in the log's Name field.
//
// The nickname is only taken when the operator asked for it AND QRZ has one —
// a blank nickname must never blank the name, which is the whole reason this is
// a fallback rather than a swap.
func qrzName(preferNickname bool, nickname, fname, name string) string {
if preferNickname {
if n := strings.TrimSpace(nickname); n != "" {
return n
}
}
return joinName(fname, name)
}
+31
View File
@@ -0,0 +1,31 @@
package lookup
import "testing"
// The nickname is the name an operator goes BY on the air, and that is what
// belongs in a log — "Bob", not "Robert J Smith". HamQTH's <nick> is already
// used that way; this brings QRZ into line for operators who ask for it.
//
// The one thing it must never do is blank the name. A published nickname is
// optional, so an empty one has to fall through to the registered name rather
// than win by being "preferred".
func TestQRZNamePrefersNicknameButFallsBack(t *testing.T) {
for _, tc := range []struct {
prefer bool
nickname, fname, name string
want string
}{
{true, "Bob", "Robert", "Smith", "Bob"},
{true, "", "Robert", "Smith", "Robert Smith"}, // no nickname published
{true, " ", "Robert", "Smith", "Robert Smith"}, // blank is not a nickname
{false, "Bob", "Robert", "Smith", "Robert Smith"}, // option off
{true, "Bob", "", "", "Bob"},
{false, "", "Robert", "", "Robert"},
{true, "", "", "Smith", "Smith"},
} {
if got := qrzName(tc.prefer, tc.nickname, tc.fname, tc.name); got != tc.want {
t.Errorf("qrzName(%v, %q, %q, %q) = %q, want %q",
tc.prefer, tc.nickname, tc.fname, tc.name, got, tc.want)
}
}
}