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
+34 -25
View File
@@ -78,6 +78,7 @@ import (
const (
keyQRZUser = "lookup.qrz.user"
keyQRZPassword = "lookup.qrz.password"
keyQRZNickname = "lookup.qrz.prefer_nickname" // "1" → QRZ <nickname> over first+last name
keyHQUser = "lookup.hamqth.user"
keyHQPassword = "lookup.hamqth.password"
keyCacheTTL = "lookup.cache.ttl_days"
@@ -232,11 +233,11 @@ const (
keyUltrabeamEnabled = "ultrabeam.enabled"
keyUltrabeamHost = "ultrabeam.host"
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
keyRowColors = "appearance.row_colors"
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
@@ -564,7 +565,11 @@ type LookupSettings struct {
Primary string `json:"primary"`
Failsafe string `json:"failsafe"`
DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
CacheTTLDays int `json:"cache_ttl_days"`
// QRZPreferNickname logs the name an operator goes BY rather than their
// registered first+last. QRZ only: HamQTH's <nick> already serves that role
// there, so the option would be a second switch for a behaviour it has.
QRZPreferNickname bool `json:"qrz_prefer_nickname"`
CacheTTLDays int `json:"cache_ttl_days"`
}
// App is the application context bound to the Wails runtime.
@@ -2596,7 +2601,7 @@ func (a *App) reloadLookupProviders() {
return
}
m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
keyQRZUser, keyQRZPassword, keyQRZNickname, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
if err != nil {
fmt.Println("OpsLog: settings load error:", err)
@@ -2610,7 +2615,9 @@ func (a *App) reloadLookupProviders() {
switch name {
case "qrz":
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
p := lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
p.PreferNickname = m[keyQRZNickname] == "1"
return p
}
case "hamqth":
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
@@ -7198,7 +7205,7 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
}
m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages)
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages, keyQRZNickname)
if err != nil {
return LookupSettings{}, err
}
@@ -7207,14 +7214,15 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
ttl = 30
}
return LookupSettings{
QRZUser: m[keyQRZUser],
QRZPassword: m[keyQRZPassword],
HamQTHUser: m[keyHQUser],
HamQTHPassword: m[keyHQPassword],
Primary: m[keyLookupPrimary],
Failsafe: m[keyLookupFailsafe],
DownloadImages: m[keyLookupImages] == "1",
CacheTTLDays: ttl,
QRZUser: m[keyQRZUser],
QRZPassword: m[keyQRZPassword],
HamQTHUser: m[keyHQUser],
HamQTHPassword: m[keyHQPassword],
Primary: m[keyLookupPrimary],
Failsafe: m[keyLookupFailsafe],
DownloadImages: m[keyLookupImages] == "1",
QRZPreferNickname: m[keyQRZNickname] == "1",
CacheTTLDays: ttl,
}, nil
}
@@ -7240,6 +7248,7 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
keyLookupPrimary: s.Primary,
keyLookupFailsafe: s.Failsafe,
keyLookupImages: boolStr(s.DownloadImages),
keyQRZNickname: boolStr(s.QRZPreferNickname),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -14822,14 +14831,14 @@ func (a steppirAdapter) Status() motorStatus {
// is kept (bindings + frontend) though it now covers SteppIR too.
type UltrabeamSettings struct {
Enabled bool `json:"enabled"`
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
Type string `json:"type"` // "ultrabeam" | "steppir"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // tcp
Port int `json:"port"` // tcp
COM string `json:"com"` // serial device
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
// When the follow loop is allowed to move the motors. The three choices the
// SteppIR's own controller software offers, because operators arrive with
// that mental model:
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "",
"en": [
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.",
"DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps."
"DX Cluster: a spot lifetime can be set — 5, 10, 15 minutes or your own value — after which spots leave the list and the band maps.",
"Call lookup: an option makes QRZ.com use the operator nickname as the name, falling back to the full name when none is published."
],
"fr": [
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.",
"Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps."
"Cluster DX : on peut fixer une durée de vie des spots — 5, 10, 15 minutes ou une valeur libre — au-delà de laquelle ils quittent la liste et les band maps.",
"Recherche d indicatif : une option fait utiliser à QRZ.com le surnom de l opérateur comme nom, avec repli sur le nom complet s il n y en a pas."
]
},
{
+18
View File
@@ -1224,6 +1224,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
hamqth_user: '', hamqth_password: '',
primary: '', failsafe: '',
download_images: false,
qrz_prefer_nickname: false,
cache_ttl_days: 30,
});
// Per-provider Test state — keeps the success/error feedback adjacent
@@ -2312,6 +2313,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</span>
</span>
</label>
{/* QRZ only. HamQTH's <nick> already fills the Name field with the
on-air name, so the same switch there would toggle a behaviour it
has no way to turn off. */}
<label className="flex items-start gap-2 text-sm cursor-pointer mt-3">
<Checkbox
checked={!!(lookup as any).qrz_prefer_nickname}
onCheckedChange={(c) => setLookup((s) => ({ ...s, qrz_prefer_nickname: !!c } as any))}
className="mt-0.5"
/>
<span>
{t('lk.qrzNickname')}
<span className="block text-xs text-muted-foreground mt-0.5">
{t('lk.qrzNicknameHint')}
</span>
</span>
</label>
</div>
<div className="mt-6 pt-4 border-t border-border">
+2 -2
View File
@@ -243,7 +243,7 @@ const en: Dict = {
'lk.provider': 'Provider', 'lk.primary': 'Primary', 'lk.failsafe': 'Failsafe', 'lk.user': 'User', 'lk.password': 'Password', 'lk.result': 'Result',
'lk.testing': 'Testing…', 'lk.test': 'Test', 'lk.testTitle': "Run a sample lookup against the active profile's callsign to verify credentials",
'lk.failsafeNote': 'Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.',
'lk.display': 'Display', 'lk.showPics': 'Show QRZ profile pictures',
'lk.display': 'Display', 'lk.qrzNickname': 'QRZ.com: use the nickname as the name', 'lk.qrzNicknameHint': 'Logs the name the operator goes by on the air rather than their registered first and last name. Falls back to the full name when no nickname is published. QRZ.com only — HamQTH already does this.', 'lk.showPics': 'Show QRZ profile pictures',
'lk.showPicsHint': 'Display the photo from QRZ.com next to the worked-before matrix. May noticeably slow lookups during busy contest days; turn off if you operate fast.',
'lk.cache': 'Cache', 'lk.cacheHint': "Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.",
'lk.ttl': 'TTL (days)', 'lk.clearing': 'Clearing…', 'lk.clearCache': 'Clear cache now',
@@ -666,7 +666,7 @@ const fr: Dict = {
'lk.provider': 'Fournisseur', 'lk.primary': 'Principal', 'lk.failsafe': 'Secours', 'lk.user': 'Utilisateur', 'lk.password': 'Mot de passe', 'lk.result': 'Résultat',
'lk.testing': 'Test…', 'lk.test': 'Test', 'lk.testTitle': "Lance une recherche test sur l'indicatif du profil actif pour vérifier les identifiants",
'lk.failsafeNote': 'Le Secours n\'est consulté que si le Principal ne trouve rien ou échoue. Décoche les deux en contest pour éviter tout accès réseau.',
'lk.display': 'Affichage', 'lk.showPics': 'Afficher les photos de profil QRZ',
'lk.display': 'Affichage', 'lk.qrzNickname': 'QRZ.com : utiliser le surnom comme nom', 'lk.qrzNicknameHint': "Enregistre le nom sous lequel l'opérateur se présente à l'air plutôt que ses prénom et nom déclarés. Retombe sur le nom complet si aucun surnom n'est publié. QRZ.com uniquement — HamQTH le fait déjà.", 'lk.showPics': 'Afficher les photos de profil QRZ',
'lk.showPicsHint': 'Affiche la photo de QRZ.com à côté de la matrice « déjà contacté ». Peut ralentir les recherches en contest ; désactive si tu opères vite.',
'lk.cache': 'Cache', 'lk.cacheHint': "Les recherches réussies sont mises en cache localement pour ne pas re-interroger le même indicatif. Le TTL contrôle la durée avant une nouvelle requête.",
'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
+2
View File
@@ -2541,6 +2541,7 @@ export namespace main {
primary: string;
failsafe: string;
download_images: boolean;
qrz_prefer_nickname: boolean;
cache_ttl_days: number;
static createFrom(source: any = {}) {
@@ -2556,6 +2557,7 @@ export namespace main {
this.primary = source["primary"];
this.failsafe = source["failsafe"];
this.download_images = source["download_images"];
this.qrz_prefer_nickname = source["qrz_prefer_nickname"];
this.cache_ttl_days = source["cache_ttl_days"];
}
}
+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)
}
}
}