This commit is contained in:
2026-05-26 01:14:43 +02:00
parent 7e518ddba3
commit 28da6f6165
9 changed files with 136 additions and 24 deletions
+36 -8
View File
@@ -37,6 +37,7 @@ const (
// Failsafe is the fallback when Primary returns not-found or errs. // Failsafe is the fallback when Primary returns not-found or errs.
keyLookupPrimary = "lookup.primary" keyLookupPrimary = "lookup.primary"
keyLookupFailsafe = "lookup.failsafe" keyLookupFailsafe = "lookup.failsafe"
keyLookupImages = "lookup.download_images" // 1 = expose QRZ ImageURL to UI
keyStationCallsign = "station.callsign" keyStationCallsign = "station.callsign"
keyStationOperator = "station.operator" keyStationOperator = "station.operator"
@@ -119,13 +120,14 @@ type StationSettings struct {
// Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to // Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to
// route lookups: primary first, failsafe on not-found / error. // route lookups: primary first, failsafe on not-found / error.
type LookupSettings struct { type LookupSettings struct {
QRZUser string `json:"qrz_user"` QRZUser string `json:"qrz_user"`
QRZPassword string `json:"qrz_password"` QRZPassword string `json:"qrz_password"`
HamQTHUser string `json:"hamqth_user"` HamQTHUser string `json:"hamqth_user"`
HamQTHPassword string `json:"hamqth_password"` HamQTHPassword string `json:"hamqth_password"`
Primary string `json:"primary"` Primary string `json:"primary"`
Failsafe string `json:"failsafe"` Failsafe string `json:"failsafe"`
CacheTTLDays int `json:"cache_ttl_days"` DownloadImages bool `json:"download_images"` // show QRZ profile pictures in the UI
CacheTTLDays int `json:"cache_ttl_days"`
} }
// App is the application context bound to the Wails runtime. // App is the application context bound to the Wails runtime.
@@ -503,9 +505,33 @@ func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
if errors.Is(err, lookup.ErrNotFound) { if errors.Is(err, lookup.ErrNotFound) {
return lookup.Result{}, fmt.Errorf("callsign not found") return lookup.Result{}, fmt.Errorf("callsign not found")
} }
// Respect the user's "Download profile images" setting: even if the
// cache holds the URL we hide it when the toggle is off so the
// frontend doesn't render the <img> (which would still fetch from
// QRZ). Cheap to check per call — settings is in-memory after init.
if err == nil && r.ImageURL != "" {
if s, _ := a.GetLookupSettings(); !s.DownloadImages {
r.ImageURL = ""
}
}
return r, err return r, err
} }
// OpenExternalURL opens a URL in the user's default browser. Wails ships
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
func (a *App) OpenExternalURL(url string) error {
url = strings.TrimSpace(url)
if url == "" {
return fmt.Errorf("empty URL")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return fmt.Errorf("only http(s) URLs allowed, got %q", url)
}
wruntime.BrowserOpenURL(a.ctx, url)
return nil
}
// GetLookupSettings returns current credentials and cache TTL. // GetLookupSettings returns current credentials and cache TTL.
func (a *App) GetLookupSettings() (LookupSettings, error) { func (a *App) GetLookupSettings() (LookupSettings, error) {
if a.settings == nil { if a.settings == nil {
@@ -513,7 +539,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) keyCacheTTL, keyLookupPrimary, keyLookupFailsafe, keyLookupImages)
if err != nil { if err != nil {
return LookupSettings{}, err return LookupSettings{}, err
} }
@@ -528,6 +554,7 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
HamQTHPassword: m[keyHQPassword], HamQTHPassword: m[keyHQPassword],
Primary: m[keyLookupPrimary], Primary: m[keyLookupPrimary],
Failsafe: m[keyLookupFailsafe], Failsafe: m[keyLookupFailsafe],
DownloadImages: m[keyLookupImages] == "1",
CacheTTLDays: ttl, CacheTTLDays: ttl,
}, nil }, nil
} }
@@ -553,6 +580,7 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
keyCacheTTL: strconv.Itoa(s.CacheTTLDays), keyCacheTTL: strconv.Itoa(s.CacheTTLDays),
keyLookupPrimary: s.Primary, keyLookupPrimary: s.Primary,
keyLookupFailsafe: s.Failsafe, keyLookupFailsafe: s.Failsafe,
keyLookupImages: boolStr(s.DownloadImages),
} { } {
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
+49 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Hash, Loader2, Lock, AlertCircle, Antenna, CheckCircle2, Clock, Compass, ExternalLink, Hash, Loader2, Lock,
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X, Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Send, Settings, Square, Trash2, Unlock, X,
} from 'lucide-react'; } from 'lucide-react';
@@ -15,6 +15,7 @@ import {
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
RefreshCtyDat, RefreshCtyDat,
RotatorGoTo, RotatorStop, RotatorGoTo, RotatorStop,
OpenExternalURL,
GetCATSettings, GetCATSettings,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime';
@@ -555,6 +556,8 @@ export default function App() {
function resetAutoFill() { function resetAutoFill() {
setName(''); setQth(''); setCountry(''); setGrid(''); setName(''); setQth(''); setCountry(''); setGrid('');
setWb(null);
setLookupResult(null);
setDetails((d) => ({ setDetails((d) => ({
...d, ...d,
state: '', cnty: '', address: '', state: '', cnty: '', address: '',
@@ -1010,6 +1013,21 @@ export default function App() {
<div className="flex flex-col w-40"> <div className="flex flex-col w-40">
<Label className="mb-1 flex items-center gap-2 h-3.5"> <Label className="mb-1 flex items-center gap-2 h-3.5">
Callsign Callsign
{callsign.trim() && (
<button
type="button"
tabIndex={-1}
onClick={() => {
const c = callsign.trim().toUpperCase();
OpenExternalURL(`https://www.qrz.com/db/${encodeURIComponent(c)}`)
.catch((err) => setError(String(err?.message ?? err)));
}}
title="Open this callsign on QRZ.com"
className="inline-flex items-center justify-center size-3.5 rounded text-muted-foreground/60 hover:text-primary transition-colors"
>
<ExternalLink className="size-3" />
</button>
)}
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>} {lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up</Badge>}
{!lookupBusy && lookupResult && ( {!lookupBusy && lookupResult && (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline"> <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider" variant="outline">
@@ -1205,7 +1223,35 @@ export default function App() {
else and let the user re-expand with the topbar toggle. */} else and let the user re-expand with the topbar toggle. */}
{compact ? null : <> {compact ? null : <>
{/* ===== BAND/SLOT GRID ===== */} {/* ===== BAND/SLOT GRID ===== */}
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} /> {/* QRZ profile picture sits next to the matrix when the user has
opted in (Settings → Lookup → Show QRZ profile pictures). The
backend returns image_url="" when the toggle is off, so we
don't need to re-check the setting here. */}
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<BandSlotGrid wb={wb} busy={wbBusy} currentBand={band} currentMode={mode} />
</div>
{lookupResult?.image_url && (
<a
href={lookupResult.image_url}
onClick={(e) => {
e.preventDefault();
OpenExternalURL(lookupResult.image_url!).catch((err) => setError(String(err?.message ?? err)));
}}
title="Open full-size on QRZ.com"
className="block shrink-0 rounded border border-border overflow-hidden hover:border-primary/60 transition-colors"
>
<img
src={lookupResult.image_url}
alt={`${callsign} profile`}
className="block w-[160px] h-[120px] object-cover bg-muted/30"
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</a>
)}
</div>
{/* ===== F2-F5 DETAILS ===== */} {/* ===== F2-F5 DETAILS ===== */}
<DetailsPanel <DetailsPanel
@@ -1340,7 +1386,7 @@ export default function App() {
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40"> <td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">
<span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-emerald-100 text-emerald-700">{q.mode}</span> <span className="inline-block px-2 py-0.5 rounded font-mono text-[11px] font-semibold bg-emerald-100 text-emerald-700">{q.mode}</span>
</td> </td>
<td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{fmtFreq(q.freq_hz)}</td> <td className="px-2.5 py-1.5 font-mono text-right whitespace-nowrap border-b border-border/40">{q.freq_hz ? fmtFreqDots(fmtFreq(q.freq_hz)) : ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_sent ?? ''}</td> <td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_sent ?? ''}</td>
<td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_rcvd ?? ''}</td> <td className="px-2.5 py-1.5 font-mono whitespace-nowrap border-b border-border/40">{q.rst_rcvd ?? ''}</td>
<td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.name ?? ''}</td> <td className="px-2.5 py-1.5 whitespace-nowrap border-b border-border/40">{q.name ?? ''}</td>
+20 -1
View File
@@ -100,7 +100,7 @@ const TREE: TreeNode[] = [
], ],
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true }, { kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
@@ -225,6 +225,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
qrz_user: '', qrz_password: '', qrz_user: '', qrz_password: '',
hamqth_user: '', hamqth_password: '', hamqth_user: '', hamqth_password: '',
primary: '', failsafe: '', primary: '', failsafe: '',
download_images: 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
@@ -712,6 +713,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely. Failsafe is consulted only when the Primary returns no match or errors. Set both to none (uncheck) during contests to skip the network entirely.
</p> </p>
<div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Display</h3>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={lookup.download_images}
onCheckedChange={(c) => setLookup((s) => ({ ...s, download_images: !!c }))}
className="mt-0.5"
/>
<span>
Show QRZ profile pictures
<span className="block text-xs text-muted-foreground mt-0.5">
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.
</span>
</span>
</label>
</div>
<div className="mt-6 pt-4 border-t border-border"> <div className="mt-6 pt-4 border-t border-border">
<h3 className="text-sm font-semibold mb-2">Cache</h3> <h3 className="text-sm font-semibold mb-2">Cache</h3>
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
+2
View File
@@ -53,6 +53,8 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenExternalURL(arg1:string):Promise<void>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
+4
View File
@@ -94,6 +94,10 @@ export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
export function OpenExternalURL(arg1) {
return window['go']['main']['App']['OpenExternalURL'](arg1);
}
export function RefreshCtyDat() { export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
+4
View File
@@ -104,6 +104,7 @@ export namespace lookup {
cont?: string; cont?: string;
email?: string; email?: string;
qsl_via?: string; qsl_via?: string;
image_url?: string;
source: string; source: string;
// Go type: time // Go type: time
fetched_at: any; fetched_at: any;
@@ -130,6 +131,7 @@ export namespace lookup {
this.cont = source["cont"]; this.cont = source["cont"];
this.email = source["email"]; this.email = source["email"];
this.qsl_via = source["qsl_via"]; this.qsl_via = source["qsl_via"];
this.image_url = source["image_url"];
this.source = source["source"]; this.source = source["source"];
this.fetched_at = this.convertValues(source["fetched_at"], null); this.fetched_at = this.convertValues(source["fetched_at"], null);
} }
@@ -252,6 +254,7 @@ export namespace main {
hamqth_password: string; hamqth_password: string;
primary: string; primary: string;
failsafe: string; failsafe: string;
download_images: boolean;
cache_ttl_days: number; cache_ttl_days: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -266,6 +269,7 @@ export namespace main {
this.hamqth_password = source["hamqth_password"]; this.hamqth_password = source["hamqth_password"];
this.primary = source["primary"]; this.primary = source["primary"];
this.failsafe = source["failsafe"]; this.failsafe = source["failsafe"];
this.download_images = source["download_images"];
this.cache_ttl_days = source["cache_ttl_days"]; this.cache_ttl_days = source["cache_ttl_days"];
} }
} }
@@ -0,0 +1,3 @@
-- QRZ profile image URL — surfaced next to the worked-before matrix when
-- the user opts in via Settings → Callsign Lookup → Download profile images.
ALTER TABLE callsign_cache ADD COLUMN image_url TEXT;
+16 -12
View File
@@ -33,7 +33,8 @@ type Result struct {
Continent string `json:"cont,omitempty"` Continent string `json:"cont,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
QSLVia string `json:"qsl_via,omitempty"` QSLVia string `json:"qsl_via,omitempty"`
Source string `json:"source"` // "qrz", "hamqth", or "cache" ImageURL string `json:"image_url,omitempty"` // profile picture URL (QRZ only for now)
Source string `json:"source"` // "qrz", "hamqth", or "cache"
FetchedAt time.Time `json:"fetched_at"` FetchedAt time.Time `json:"fetched_at"`
} }
@@ -204,21 +205,21 @@ func (c *Cache) SetTTL(ttl time.Duration) {
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) { func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
row := c.db.QueryRowContext(ctx, ` row := c.db.QueryRowContext(ctx, `
SELECT callsign, name, qth, address, state, cnty, country, grid, SELECT callsign, name, qth, address, state, cnty, country, grid,
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
source, fetched_at source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign) FROM callsign_cache WHERE callsign = ?`, callsign)
var ( var (
r Result r Result
name, qth, addr, state, cnty sql.NullString name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia sql.NullString country, grid, cont, email, qslVia, image sql.NullString
src string src string
dxcc, cqz, ituz sql.NullInt64 dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64 lat, lon sql.NullFloat64
fetched string fetched string
) )
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty, if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon, &country, &grid, &lat, &lon,
&dxcc, &cqz, &ituz, &cont, &email, &qslVia, &dxcc, &cqz, &ituz, &cont, &email, &qslVia, &image,
&src, &fetched); err != nil { &src, &fetched); err != nil {
return Result{}, false return Result{}, false
} }
@@ -241,6 +242,7 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
r.Continent = cont.String r.Continent = cont.String
r.Email = email.String r.Email = email.String
r.QSLVia = qslVia.String r.QSLVia = qslVia.String
r.ImageURL = image.String
r.DXCC = int(dxcc.Int64) r.DXCC = int(dxcc.Int64)
r.CQZ = int(cqz.Int64) r.CQZ = int(cqz.Int64)
r.ITUZ = int(ituz.Int64) r.ITUZ = int(ituz.Int64)
@@ -254,9 +256,9 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, ` _, err := c.db.ExecContext(ctx, `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty, INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon, country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
source, fetched_at) source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?, ?, VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?,?, ?,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(callsign) DO UPDATE SET ON CONFLICT(callsign) DO UPDATE SET
name = excluded.name, qth = excluded.qth, address = excluded.address, name = excluded.name, qth = excluded.qth, address = excluded.address,
@@ -265,6 +267,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
lat = excluded.lat, lon = excluded.lon, lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz, dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via, cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
image_url = excluded.image_url,
source = excluded.source, fetched_at = excluded.fetched_at`, source = excluded.source, fetched_at = excluded.fetched_at`,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address), r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County), nullable(r.State), nullable(r.County),
@@ -272,6 +275,7 @@ func (c *Cache) Put(ctx context.Context, r Result) error {
nullableFloat(r.Lat), nullableFloat(r.Lon), nullableFloat(r.Lat), nullableFloat(r.Lon),
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ), nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia), nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
nullable(r.ImageURL),
r.Source, r.Source,
) )
return err return err
+2
View File
@@ -127,6 +127,7 @@ func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, e
Continent: strings.ToUpper(c.Continent), Continent: strings.ToUpper(c.Continent),
Email: c.Email, Email: c.Email,
QSLVia: c.QSLMgr, QSLVia: c.QSLMgr,
ImageURL: strings.TrimSpace(c.Image),
} }
r.Lat, _ = strconv.ParseFloat(c.Lat, 64) r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
r.Lon, _ = strconv.ParseFloat(c.Lon, 64) r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
@@ -184,6 +185,7 @@ type qrzCallsign struct {
Continent string `xml:"cont"` Continent string `xml:"cont"`
Email string `xml:"email"` Email string `xml:"email"`
QSLMgr string `xml:"qslmgr"` QSLMgr string `xml:"qslmgr"`
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
} }
// composeQRZAddress builds a multi-line postal address from QRZ's separate // composeQRZAddress builds a multi-line postal address from QRZ's separate