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