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
+12 -3
View File
@@ -78,6 +78,7 @@ import (
const ( const (
keyQRZUser = "lookup.qrz.user" keyQRZUser = "lookup.qrz.user"
keyQRZPassword = "lookup.qrz.password" keyQRZPassword = "lookup.qrz.password"
keyQRZNickname = "lookup.qrz.prefer_nickname" // "1" → QRZ <nickname> over first+last name
keyHQUser = "lookup.hamqth.user" keyHQUser = "lookup.hamqth.user"
keyHQPassword = "lookup.hamqth.password" keyHQPassword = "lookup.hamqth.password"
keyCacheTTL = "lookup.cache.ttl_days" keyCacheTTL = "lookup.cache.ttl_days"
@@ -564,6 +565,10 @@ type LookupSettings struct {
Primary string `json:"primary"` Primary string `json:"primary"`
Failsafe string `json:"failsafe"` Failsafe string `json:"failsafe"`
DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
// 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"` CacheTTLDays int `json:"cache_ttl_days"`
} }
@@ -2596,7 +2601,7 @@ func (a *App) reloadLookupProviders() {
return return
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyQRZUser, keyQRZPassword, keyQRZNickname, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe) keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
if err != nil { if err != nil {
fmt.Println("OpsLog: settings load error:", err) fmt.Println("OpsLog: settings load error:", err)
@@ -2610,7 +2615,9 @@ func (a *App) reloadLookupProviders() {
switch name { switch name {
case "qrz": case "qrz":
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" { 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": case "hamqth":
if m[keyHQUser] != "" && m[keyHQPassword] != "" { if m[keyHQUser] != "" && m[keyHQPassword] != "" {
@@ -7198,7 +7205,7 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
} }
m, err := a.settings.GetMany(a.ctx, m, err := a.settings.GetMany(a.ctx,
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword, keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages) keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages, keyQRZNickname)
if err != nil { if err != nil {
return LookupSettings{}, err return LookupSettings{}, err
} }
@@ -7214,6 +7221,7 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
Primary: m[keyLookupPrimary], Primary: m[keyLookupPrimary],
Failsafe: m[keyLookupFailsafe], Failsafe: m[keyLookupFailsafe],
DownloadImages: m[keyLookupImages] == "1", DownloadImages: m[keyLookupImages] == "1",
QRZPreferNickname: m[keyQRZNickname] == "1",
CacheTTLDays: ttl, CacheTTLDays: ttl,
}, nil }, nil
} }
@@ -7240,6 +7248,7 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
keyLookupPrimary: s.Primary, keyLookupPrimary: s.Primary,
keyLookupFailsafe: s.Failsafe, keyLookupFailsafe: s.Failsafe,
keyLookupImages: boolStr(s.DownloadImages), keyLookupImages: boolStr(s.DownloadImages),
keyQRZNickname: boolStr(s.QRZPreferNickname),
} { } {
if err := a.settings.Set(a.ctx, k, v); err != nil { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "", "date": "",
"en": [ "en": [
"Cluster: \"Hide worked\" no longer hides a spot that is a new prefix, county, grid or park in an entity already worked.", "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": [ "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 : « 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: '', hamqth_user: '', hamqth_password: '',
primary: '', failsafe: '', primary: '', failsafe: '',
download_images: false, download_images: false,
qrz_prefer_nickname: false,
cache_ttl_days: 30, cache_ttl_days: 30,
}); });
// Per-provider Test state — keeps the success/error feedback adjacent // Per-provider Test state — keeps the success/error feedback adjacent
@@ -2312,6 +2313,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</span> </span>
</span> </span>
</label> </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>
<div className="mt-6 pt-4 border-t border-border"> <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.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.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.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.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.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', '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.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.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.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.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.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', 'lk.ttl': 'TTL (jours)', 'lk.clearing': 'Effacement…', 'lk.clearCache': 'Vider le cache',
+2
View File
@@ -2541,6 +2541,7 @@ export namespace main {
primary: string; primary: string;
failsafe: string; failsafe: string;
download_images: boolean; download_images: boolean;
qrz_prefer_nickname: boolean;
cache_ttl_days: number; cache_ttl_days: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -2556,6 +2557,7 @@ export namespace main {
this.primary = source["primary"]; this.primary = source["primary"];
this.failsafe = source["failsafe"]; this.failsafe = source["failsafe"];
this.download_images = source["download_images"]; this.download_images = source["download_images"];
this.qrz_prefer_nickname = source["qrz_prefer_nickname"];
this.cache_ttl_days = source["cache_ttl_days"]; this.cache_ttl_days = source["cache_ttl_days"];
} }
} }
+22 -1
View File
@@ -21,6 +21,12 @@ type QRZ struct {
HTTP *http.Client HTTP *http.Client
// 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 mu sync.Mutex
session string session string
loggedAt time.Time loggedAt time.Time
@@ -117,7 +123,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
} }
r := Result{ r := Result{
Callsign: strings.ToUpper(c.Call), Callsign: strings.ToUpper(c.Call),
Name: joinName(c.FName, c.Name), Name: qrzName(q.PreferNickname, c.Nickname, c.FName, c.Name),
QTH: c.Addr2, QTH: c.Addr2,
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country), Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
State: strings.ToUpper(c.State), State: strings.ToUpper(c.State),
@@ -169,6 +175,7 @@ type qrzSession struct {
type qrzCallsign struct { type qrzCallsign struct {
Call string `xml:"call"` Call string `xml:"call"`
FName string `xml:"fname"` FName string `xml:"fname"`
Nickname string `xml:"nickname"` // the name the operator goes by on the air
Name string `xml:"name"` Name string `xml:"name"`
Addr1 string `xml:"addr1"` Addr1 string `xml:"addr1"`
Addr2 string `xml:"addr2"` Addr2 string `xml:"addr2"`
@@ -235,3 +242,17 @@ func firstNonEmpty(s ...string) string {
} }
return "" 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)
}
}
}