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:
@@ -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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user