Initial codebase: Go + Wails amateur radio logbook

Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:16:45 +02:00
parent 734d296300
commit 7ace2cc602
87 changed files with 15892 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package lookup
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// HamQTH is a lookup.Provider for hamqth.com (free with registration).
type HamQTH struct {
User string
Password string
HTTP *http.Client
mu sync.Mutex
session string
loggedAt time.Time
}
func NewHamQTH(user, password string) *HamQTH {
return &HamQTH{
User: user,
Password: password,
HTTP: &http.Client{Timeout: 12 * time.Second},
}
}
func (h *HamQTH) Name() string { return "hamqth" }
func (h *HamQTH) Lookup(ctx context.Context, callsign string) (Result, error) {
if h.User == "" || h.Password == "" {
return Result{}, fmt.Errorf("hamqth: credentials not set")
}
id, err := h.sessionID(ctx)
if err != nil {
return Result{}, err
}
r, err := h.fetch(ctx, id, callsign)
if err == errHamQTHSessionExpired {
h.mu.Lock()
h.session = ""
h.mu.Unlock()
id, err = h.sessionID(ctx)
if err != nil {
return Result{}, err
}
r, err = h.fetch(ctx, id, callsign)
}
return r, err
}
func (h *HamQTH) sessionID(ctx context.Context) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
// HamQTH sessions stay valid ~1h; re-login after 30min defensively.
if h.session != "" && time.Since(h.loggedAt) < 30*time.Minute {
return h.session, nil
}
u := fmt.Sprintf("https://www.hamqth.com/xml.php?u=%s&p=%s",
url.QueryEscape(h.User), url.QueryEscape(h.Password))
body, err := h.get(ctx, u)
if err != nil {
return "", err
}
var resp hamqthRoot
if err := xml.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("hamqth: parse session: %w", err)
}
if resp.Session.Error != "" {
return "", fmt.Errorf("hamqth login: %s", resp.Session.Error)
}
if resp.Session.ID == "" {
return "", fmt.Errorf("hamqth: empty session id")
}
h.session = resp.Session.ID
h.loggedAt = time.Now()
return h.session, nil
}
var errHamQTHSessionExpired = fmt.Errorf("hamqth: session expired")
func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result, error) {
u := fmt.Sprintf("https://www.hamqth.com/xml.php?id=%s&callsign=%s&prg=HamLog",
url.QueryEscape(sessionID), url.QueryEscape(callsign))
body, err := h.get(ctx, u)
if err != nil {
return Result{}, err
}
var resp hamqthRoot
if err := xml.Unmarshal(body, &resp); err != nil {
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
}
if resp.Session.Error != "" {
msg := strings.ToLower(resp.Session.Error)
if strings.Contains(msg, "session") || strings.Contains(msg, "expired") {
return Result{}, errHamQTHSessionExpired
}
if strings.Contains(msg, "not found") || strings.Contains(msg, "callsign") {
return Result{}, ErrNotFound
}
return Result{}, fmt.Errorf("hamqth: %s", resp.Session.Error)
}
s := resp.Search
if s.Callsign == "" {
return Result{}, ErrNotFound
}
r := Result{
Callsign: strings.ToUpper(s.Callsign),
Name: strings.TrimSpace(s.Nick + " " + s.LastName),
QTH: firstNonEmpty(s.QTH, s.AdrCity),
Address: s.AdrStreet1,
State: strings.ToUpper(s.USState),
County: s.USCounty,
Country: firstNonEmpty(s.AdrCountry, s.Country),
Grid: strings.ToUpper(s.Grid),
Continent: strings.ToUpper(s.Continent),
Email: s.Email,
QSLVia: s.QSLVia,
}
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
r.DXCC, _ = strconv.Atoi(s.DXCC)
r.CQZ, _ = strconv.Atoi(s.CQ)
r.ITUZ, _ = strconv.Atoi(s.ITU)
return r, nil
}
func (h *HamQTH) get(ctx context.Context, u string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := h.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("hamqth http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("hamqth http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ----- XML shapes -----
type hamqthRoot struct {
XMLName xml.Name `xml:"HamQTH"`
Session hamqthSession `xml:"session"`
Search hamqthSearch `xml:"search"`
}
type hamqthSession struct {
ID string `xml:"session_id"`
Error string `xml:"error"`
}
type hamqthSearch struct {
Callsign string `xml:"callsign"`
Nick string `xml:"nick"`
LastName string `xml:"name"`
QTH string `xml:"qth"`
AdrStreet1 string `xml:"adr_street1"`
AdrCity string `xml:"adr_city"`
Country string `xml:"country"`
AdrCountry string `xml:"adr_country"`
USState string `xml:"us_state"`
USCounty string `xml:"us_county"`
Grid string `xml:"grid"`
Latitude string `xml:"latitude"`
Longitude string `xml:"longitude"`
DXCC string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under <adif>
CQ string `xml:"cq"`
ITU string `xml:"itu"`
Continent string `xml:"continent"`
Email string `xml:"email"`
QSLVia string `xml:"qsl_via"`
}
+304
View File
@@ -0,0 +1,304 @@
// Package lookup queries callsign databases (QRZ.com, HamQTH) and caches
// results locally so we don't re-hit the network for known calls.
package lookup
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// ErrNotFound is returned by providers when a callsign is unknown.
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"`
Source string `json:"source"` // "qrz", "hamqth", or "cache"
FetchedAt time.Time `json:"fetched_at"`
}
// Provider is the contract implemented by QRZ, HamQTH, etc.
type Provider interface {
Name() string
Lookup(ctx context.Context, callsign string) (Result, error)
}
// DXCCResolver fills the country / zones / continent when the providers
// don't (or when no provider returned anything). Decoupled via interface so
// `lookup` doesn't import the dxcc package directly.
type DXCCResolver interface {
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool)
}
// Manager composes a cache with one or more providers.
// Lookup tries the cache first, then each enabled provider in order.
type Manager struct {
mu sync.RWMutex
providers []Provider
cache *Cache
dxcc DXCCResolver
}
func NewManager(cache *Cache) *Manager {
return &Manager{cache: cache}
}
// SetDXCCResolver wires the cty.dat-backed fallback that fills country/
// zones when the provider chain comes up dry or short.
func (m *Manager) SetDXCCResolver(r DXCCResolver) {
m.mu.Lock()
defer m.mu.Unlock()
m.dxcc = r
}
// SetProviders replaces the provider chain. Safe to call at any time
// (e.g. after the user updates credentials in settings).
func (m *Manager) SetProviders(p ...Provider) {
m.mu.Lock()
defer m.mu.Unlock()
m.providers = p
}
// Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails.
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return Result{}, fmt.Errorf("empty callsign")
}
if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache"
return r, nil
}
m.mu.RLock()
providers := append([]Provider(nil), m.providers...)
dxcc := m.dxcc
m.mu.RUnlock()
var lastErr error
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
_ = m.cache.Put(ctx, r)
return r, nil
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
// All providers exhausted (not-found or errored). Try the cty.dat
// resolver as a last resort — at least we can hand back country/zones
// even for unknown callsigns. Not cached: a "cty.dat-only" result
// shouldn't suppress a later real lookup if the user adds creds.
if dxcc != nil {
var r Result
r.Callsign = call
if fillFromDXCC(&r, dxcc) {
r.Source = "cty.dat"
r.FetchedAt = time.Now().UTC()
return r, nil
}
}
if lastErr == nil {
if len(providers) == 0 {
lastErr = fmt.Errorf("no lookup provider configured")
} else {
lastErr = ErrNotFound
}
}
return Result{}, lastErr
}
// fillFromDXCC fills in country/continent/zones/lat/lon from the cty.dat
// resolver when the provider returned them empty. Provider data wins.
// Returns true if any field was filled.
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
}
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
if !ok {
return false
}
filled := false
if r.Country == "" && country != "" {
r.Country = country
filled = true
}
if r.Continent == "" && cont != "" {
r.Continent = cont
filled = true
}
if r.CQZ == 0 && cqz != 0 {
r.CQZ = cqz
filled = true
}
if r.ITUZ == 0 && ituz != 0 {
r.ITUZ = ituz
filled = true
}
if r.Lat == 0 && lat != 0 {
r.Lat = lat
filled = true
}
if r.Lon == 0 && lon != 0 {
r.Lon = lon
filled = true
}
return filled
}
// ----- Cache -----
// Cache is a SQLite-backed cache of lookup results with a TTL.
type Cache struct {
db *sql.DB
ttl time.Duration
}
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return &Cache{db: db, ttl: ttl}
}
// SetTTL updates the cache TTL (e.g. when user changes settings).
func (c *Cache) SetTTL(ttl time.Duration) {
if ttl > 0 {
c.ttl = ttl
}
}
// Get returns the cached result if present and not expired.
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
row := c.db.QueryRowContext(ctx, `
SELECT callsign, name, qth, address, state, cnty, country, grid,
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via,
source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
)
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon,
&dxcc, &cqz, &ituz, &cont, &email, &qslVia,
&src, &fetched); err != nil {
return Result{}, false
}
t, err := time.Parse("2006-01-02T15:04:05.000Z", fetched)
if err != nil {
t, _ = time.Parse(time.RFC3339, fetched)
}
if time.Since(t) > c.ttl {
return Result{}, false
}
r.Name = name.String
r.QTH = qth.String
r.Address = addr.String
r.State = state.String
r.County = cnty.String
r.Country = country.String
r.Grid = grid.String
r.Lat = lat.Float64
r.Lon = lon.Float64
r.Continent = cont.String
r.Email = email.String
r.QSLVia = qslVia.String
r.DXCC = int(dxcc.Int64)
r.CQZ = int(cqz.Int64)
r.ITUZ = int(ituz.Int64)
r.Source = src
r.FetchedAt = t
return r, true
}
// Put upserts a lookup result.
func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via,
source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?, ?,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(callsign) DO UPDATE SET
name = excluded.name, qth = excluded.qth, address = excluded.address,
state = excluded.state, cnty = excluded.cnty,
country = excluded.country, grid = excluded.grid,
lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
source = excluded.source, fetched_at = excluded.fetched_at`,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County),
nullable(r.Country), nullable(r.Grid),
nullableFloat(r.Lat), nullableFloat(r.Lon),
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
r.Source,
)
return err
}
func nullableFloat(f float64) any {
if f == 0 {
return nil
}
return f
}
// Clear empties the cache. Useful for "Refresh cache" admin actions.
func (c *Cache) Clear(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `DELETE FROM callsign_cache`)
return err
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func nullableInt(n int) any {
if n == 0 {
return nil
}
return n
}
+235
View File
@@ -0,0 +1,235 @@
package lookup
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// QRZ is a lookup.Provider for xmldata.qrz.com.
// A QRZ subscription is required for full data; basic info is free.
type QRZ struct {
User string
Password string
HTTP *http.Client
mu sync.Mutex
session string
loggedAt time.Time
}
func NewQRZ(user, password string) *QRZ {
return &QRZ{
User: user,
Password: password,
HTTP: &http.Client{Timeout: 12 * time.Second},
}
}
func (q *QRZ) Name() string { return "qrz" }
// Lookup queries QRZ for the given callsign.
func (q *QRZ) Lookup(ctx context.Context, callsign string) (Result, error) {
if q.User == "" || q.Password == "" {
return Result{}, fmt.Errorf("qrz: credentials not set")
}
key, err := q.sessionKey(ctx)
if err != nil {
return Result{}, err
}
r, err := q.fetch(ctx, key, callsign)
if err == errQRZSessionExpired {
// Force re-login and retry once.
q.mu.Lock()
q.session = ""
q.mu.Unlock()
key, err = q.sessionKey(ctx)
if err != nil {
return Result{}, err
}
r, err = q.fetch(ctx, key, callsign)
}
return r, err
}
func (q *QRZ) sessionKey(ctx context.Context) (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
// QRZ sessions are valid ~24h; re-login defensively after 12h.
if q.session != "" && time.Since(q.loggedAt) < 12*time.Hour {
return q.session, nil
}
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?username=%s;password=%s;agent=HamLog",
url.QueryEscape(q.User), url.QueryEscape(q.Password))
body, err := q.get(ctx, u)
if err != nil {
return "", err
}
var resp qrzDB
if err := xml.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("qrz: parse session: %w", err)
}
if resp.Session.Error != "" {
return "", fmt.Errorf("qrz login: %s", resp.Session.Error)
}
if resp.Session.Key == "" {
return "", fmt.Errorf("qrz: empty session key")
}
q.session = resp.Session.Key
q.loggedAt = time.Now()
return q.session, nil
}
var errQRZSessionExpired = fmt.Errorf("qrz: session expired")
func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, error) {
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?s=%s;callsign=%s",
url.QueryEscape(sessionKey), url.QueryEscape(callsign))
body, err := q.get(ctx, u)
if err != nil {
return Result{}, err
}
var resp qrzDB
if err := xml.Unmarshal(body, &resp); err != nil {
return Result{}, fmt.Errorf("qrz: parse callsign: %w", err)
}
if resp.Session.Error != "" {
msg := strings.ToLower(resp.Session.Error)
if strings.Contains(msg, "session") || strings.Contains(msg, "invalid") {
return Result{}, errQRZSessionExpired
}
if strings.Contains(msg, "not found") {
return Result{}, ErrNotFound
}
return Result{}, fmt.Errorf("qrz: %s", resp.Session.Error)
}
c := resp.Callsign
if c.Call == "" {
return Result{}, ErrNotFound
}
r := Result{
Callsign: strings.ToUpper(c.Call),
Name: joinName(c.FName, c.Name),
QTH: c.Addr2,
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
State: strings.ToUpper(c.State),
County: c.County,
Country: c.Country,
Grid: strings.ToUpper(c.Grid),
Continent: strings.ToUpper(c.Continent),
Email: c.Email,
QSLVia: c.QSLMgr,
}
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
r.DXCC, _ = strconv.Atoi(c.DXCC)
r.CQZ, _ = strconv.Atoi(c.CQZone)
r.ITUZ, _ = strconv.Atoi(c.ITUZone)
return r, nil
}
func (q *QRZ) get(ctx context.Context, u string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := q.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("qrz http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qrz http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ----- XML shapes -----
type qrzDB struct {
XMLName xml.Name `xml:"QRZDatabase"`
Session qrzSession `xml:"Session"`
Callsign qrzCallsign `xml:"Callsign"`
}
type qrzSession struct {
Key string `xml:"Key"`
Error string `xml:"Error"`
}
type qrzCallsign struct {
Call string `xml:"call"`
FName string `xml:"fname"`
Name string `xml:"name"`
Addr1 string `xml:"addr1"`
Addr2 string `xml:"addr2"`
Zip string `xml:"zip"`
State string `xml:"state"`
County string `xml:"county"`
Country string `xml:"country"`
Grid string `xml:"grid"`
Lat string `xml:"lat"`
Lon string `xml:"lon"`
DXCC string `xml:"dxcc"`
CQZone string `xml:"cqzone"`
ITUZone string `xml:"ituzone"`
Continent string `xml:"cont"`
Email string `xml:"email"`
QSLMgr string `xml:"qslmgr"`
}
// composeQRZAddress builds a multi-line postal address from QRZ's separate
// fields (street, city, zip, country) the same way Log4OM displays it.
// addr1 is the street, addr2 is the city — skip addr1 when it duplicates
// the city (common for users who only filled the city).
func composeQRZAddress(addr1, addr2, zip, country string) string {
addr1 = strings.TrimSpace(addr1)
addr2 = strings.TrimSpace(addr2)
zip = strings.TrimSpace(zip)
country = strings.TrimSpace(country)
var parts []string
if addr1 != "" && !strings.EqualFold(addr1, addr2) {
parts = append(parts, addr1)
}
if addr2 != "" {
parts = append(parts, addr2)
}
if zip != "" {
parts = append(parts, zip)
}
if country != "" {
parts = append(parts, country)
}
return strings.Join(parts, ", ")
}
func joinName(first, last string) string {
first = strings.TrimSpace(first)
last = strings.TrimSpace(last)
switch {
case first != "" && last != "":
return first + " " + last
case first != "":
return first
default:
return last
}
}
func firstNonEmpty(s ...string) string {
for _, v := range s {
v = strings.TrimSpace(v)
if v != "" {
return v
}
}
return ""
}