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,938 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/adif"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
"hamlog/internal/lookup"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/settings"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// Setting keys.
|
||||
const (
|
||||
keyQRZUser = "lookup.qrz.user"
|
||||
keyQRZPassword = "lookup.qrz.password"
|
||||
keyHQUser = "lookup.hamqth.user"
|
||||
keyHQPassword = "lookup.hamqth.password"
|
||||
keyCacheTTL = "lookup.cache.ttl_days"
|
||||
// Provider routing. Each value is a provider name (qrz | hamqth)
|
||||
// or empty to disable that slot. Primary is consulted first;
|
||||
// Failsafe is the fallback when Primary returns not-found or errs.
|
||||
keyLookupPrimary = "lookup.primary"
|
||||
keyLookupFailsafe = "lookup.failsafe"
|
||||
|
||||
keyStationCallsign = "station.callsign"
|
||||
keyStationOperator = "station.operator"
|
||||
keyStationMyGrid = "station.my_grid"
|
||||
keyStationCountry = "station.my_country"
|
||||
keyStationSOTA = "station.my_sota_ref"
|
||||
keyStationPOTA = "station.my_pota_ref"
|
||||
|
||||
keyListsBands = "lists.bands"
|
||||
keyListsModes = "lists.modes"
|
||||
|
||||
keyCATEnabled = "cat.enabled"
|
||||
keyCATBackend = "cat.backend" // "omnirig" (only one for now)
|
||||
keyCATOmniRigNum = "cat.omnirig.rig" // 1 or 2
|
||||
keyCATPollMs = "cat.poll_ms"
|
||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
)
|
||||
|
||||
// CATSettings is the user-tweakable rig-control configuration. Stored as
|
||||
// individual key/value pairs to keep the settings table flat.
|
||||
type CATSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Backend string `json:"backend"` // currently always "omnirig"
|
||||
OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot)
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
}
|
||||
|
||||
// ModePreset is a mode entry with default RST values to auto-populate
|
||||
// the entry form when the user picks this mode.
|
||||
type ModePreset struct {
|
||||
Name string `json:"name"`
|
||||
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
|
||||
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
|
||||
}
|
||||
|
||||
// ListsSettings holds the user-customisable dropdown lists used by the
|
||||
// entry form. Default values match common HF/VHF practice.
|
||||
type ListsSettings struct {
|
||||
Bands []string `json:"bands"`
|
||||
Modes []ModePreset `json:"modes"`
|
||||
}
|
||||
|
||||
var defaultBands = []string{
|
||||
"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
|
||||
"12m", "10m", "6m", "2m", "70cm", "23cm",
|
||||
}
|
||||
var defaultModes = []ModePreset{
|
||||
{Name: "SSB", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
{Name: "CW", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||||
{Name: "FT8", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
|
||||
{Name: "FT4", DefaultRSTSent: "+00", DefaultRSTRcvd: "+00"},
|
||||
{Name: "RTTY", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||||
{Name: "PSK31", DefaultRSTSent: "599", DefaultRSTRcvd: "599"},
|
||||
{Name: "AM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
{Name: "FM", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
{Name: "DIGITALVOICE", DefaultRSTSent: "59", DefaultRSTRcvd: "59"},
|
||||
}
|
||||
|
||||
// StationSettings holds the active operator profile. Used to stamp every
|
||||
// new QSO so we don't ask the user to retype it for each contact.
|
||||
// Multi-profile support (portable / SOTA …) will layer on top of this.
|
||||
type StationSettings struct {
|
||||
Callsign string `json:"callsign"`
|
||||
Operator string `json:"operator"`
|
||||
MyGrid string `json:"my_grid"`
|
||||
MyCountry string `json:"my_country"`
|
||||
MySOTARef string `json:"my_sota_ref"`
|
||||
MyPOTARef string `json:"my_pota_ref"`
|
||||
}
|
||||
|
||||
// LookupSettings is the JSON shape exchanged with the frontend.
|
||||
// Primary / Failsafe hold a provider name ("qrz" | "hamqth" | "") to
|
||||
// route lookups: primary first, failsafe on not-found / error.
|
||||
type LookupSettings struct {
|
||||
QRZUser string `json:"qrz_user"`
|
||||
QRZPassword string `json:"qrz_password"`
|
||||
HamQTHUser string `json:"hamqth_user"`
|
||||
HamQTHPassword string `json:"hamqth_password"`
|
||||
Primary string `json:"primary"`
|
||||
Failsafe string `json:"failsafe"`
|
||||
CacheTTLDays int `json:"cache_ttl_days"`
|
||||
}
|
||||
|
||||
// App is the application context bound to the Wails runtime.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
qso *qso.Repo
|
||||
settings *settings.Store
|
||||
profiles *profile.Repo
|
||||
lookup *lookup.Manager
|
||||
cache *lookup.Cache
|
||||
cat *cat.Manager
|
||||
dxcc *dxcc.Manager
|
||||
startupErr string // captured for surfacing to the frontend
|
||||
dbPath string
|
||||
}
|
||||
|
||||
// dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface
|
||||
// without making the lookup package import dxcc.
|
||||
type dxccAdapter struct{ m *dxcc.Manager }
|
||||
|
||||
func (a dxccAdapter) Resolve(call string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool) {
|
||||
if a.m == nil {
|
||||
return
|
||||
}
|
||||
mm, found := a.m.Lookup(call)
|
||||
if !found || mm.Entity == nil {
|
||||
return
|
||||
}
|
||||
return mm.Entity.Name, mm.Continent, mm.CQZone, mm.ITUZone, mm.Lat, mm.Lon, true
|
||||
}
|
||||
|
||||
func NewApp() *App { return &App{} }
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
dataDir, err := userDataDir()
|
||||
if err != nil {
|
||||
a.startupErr = "cannot resolve data dir: " + err.Error()
|
||||
fmt.Println("HamLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
a.startupErr = "cannot create data dir: " + err.Error()
|
||||
fmt.Println("HamLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.dbPath = filepath.Join(dataDir, "hamlog.db")
|
||||
conn, err := db.Open(a.dbPath)
|
||||
if err != nil {
|
||||
a.startupErr = "cannot open db: " + err.Error()
|
||||
fmt.Println("HamLog:", a.startupErr)
|
||||
return
|
||||
}
|
||||
a.db = conn
|
||||
a.qso = qso.NewRepo(conn)
|
||||
a.settings = settings.NewStore(conn)
|
||||
a.profiles = profile.NewRepo(conn)
|
||||
// On first run, copy the legacy single-station settings into a
|
||||
// "Default" profile so the user's existing config carries over without
|
||||
// any manual step. Subsequent runs just confirm an active profile.
|
||||
if _, err := profile.EnsureDefault(a.ctx, conn, a.settings, profile.LegacyStationKeys{
|
||||
Callsign: keyStationCallsign,
|
||||
Operator: keyStationOperator,
|
||||
MyGrid: keyStationMyGrid,
|
||||
Country: keyStationCountry,
|
||||
SOTA: keyStationSOTA,
|
||||
POTA: keyStationPOTA,
|
||||
}); err != nil {
|
||||
fmt.Println("HamLog: EnsureDefault profile:", err)
|
||||
}
|
||||
a.cache = lookup.NewCache(conn, 30*24*time.Hour)
|
||||
a.lookup = lookup.NewManager(a.cache)
|
||||
a.reloadLookupProviders()
|
||||
|
||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||
// run downloads it from country-files.com in the background so startup
|
||||
// stays fast even if the network is slow.
|
||||
a.dxcc = dxcc.NewManager(dataDir)
|
||||
a.lookup.SetDXCCResolver(dxccAdapter{m: a.dxcc})
|
||||
go func() {
|
||||
if err := a.dxcc.EnsureLoaded(context.Background()); err != nil {
|
||||
fmt.Println("HamLog: cty.dat unavailable —", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("HamLog: cty.dat loaded —", a.dxcc.Info().Entities, "entities")
|
||||
}()
|
||||
// CAT manager: emit pushes state to the frontend via Wails events.
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
fmt.Println("HamLog: db ready at", a.dbPath)
|
||||
}
|
||||
|
||||
// StartupStatus returns a diagnostic snapshot for the frontend.
|
||||
// dbPath is always populated; err is empty when the app is healthy.
|
||||
type StartupStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
Err string `json:"err"`
|
||||
DBPath string `json:"db_path"`
|
||||
}
|
||||
|
||||
// GetStartupStatus exposes whatever happened during startup so the UI
|
||||
// can show a useful error instead of just "db not initialized".
|
||||
func (a *App) GetStartupStatus() StartupStatus {
|
||||
return StartupStatus{
|
||||
OK: a.startupErr == "",
|
||||
Err: a.startupErr,
|
||||
DBPath: a.dbPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) shutdown(ctx context.Context) {
|
||||
if a.db != nil {
|
||||
_ = a.db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func userDataDir() (string, error) {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(base, "HamLog"), nil
|
||||
}
|
||||
|
||||
// reloadLookupProviders rebuilds the lookup chain from current settings.
|
||||
// Called at startup and after the user saves new credentials.
|
||||
//
|
||||
// Provider order honours the user's primary/failsafe choice. If they
|
||||
// haven't picked one yet (fresh install), we default to "primary = first
|
||||
// provider with creds" so the app still works out of the box.
|
||||
func (a *App) reloadLookupProviders() {
|
||||
if a.lookup == nil {
|
||||
return
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
||||
if err != nil {
|
||||
fmt.Println("HamLog: settings load error:", err)
|
||||
return
|
||||
}
|
||||
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
||||
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
build := func(name string) lookup.Provider {
|
||||
switch name {
|
||||
case "qrz":
|
||||
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
|
||||
return lookup.NewQRZ(m[keyQRZUser], m[keyQRZPassword])
|
||||
}
|
||||
case "hamqth":
|
||||
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
||||
return lookup.NewHamQTH(m[keyHQUser], m[keyHQPassword])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
primary, failsafe := m[keyLookupPrimary], m[keyLookupFailsafe]
|
||||
// Fresh install fallback: prefer QRZ over HamQTH when both creds exist.
|
||||
if primary == "" && failsafe == "" {
|
||||
if m[keyQRZUser] != "" && m[keyQRZPassword] != "" {
|
||||
primary = "qrz"
|
||||
if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
||||
failsafe = "hamqth"
|
||||
}
|
||||
} else if m[keyHQUser] != "" && m[keyHQPassword] != "" {
|
||||
primary = "hamqth"
|
||||
}
|
||||
}
|
||||
|
||||
var providers []lookup.Provider
|
||||
if p := build(primary); p != nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
if failsafe != "" && failsafe != primary {
|
||||
if p := build(failsafe); p != nil {
|
||||
providers = append(providers, p)
|
||||
}
|
||||
}
|
||||
a.lookup.SetProviders(providers...)
|
||||
}
|
||||
|
||||
// --- QSO bindings ---
|
||||
|
||||
func (a *App) AddQSO(q qso.QSO) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
a.applyStationDefaults(&q)
|
||||
return a.qso.Add(a.ctx, q)
|
||||
}
|
||||
|
||||
// applyStationDefaults fills any empty MY_* / station field on q with the
|
||||
// currently-active profile's values. Multi-profile support means a user
|
||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||
// QSO carries whichever profile was selected at log time.
|
||||
func (a *App) applyStationDefaults(q *qso.QSO) {
|
||||
if a.profiles == nil {
|
||||
return
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if q.StationCallsign == "" {
|
||||
q.StationCallsign = p.Callsign
|
||||
}
|
||||
if q.Operator == "" {
|
||||
q.Operator = p.Operator
|
||||
}
|
||||
if q.MyGrid == "" {
|
||||
q.MyGrid = p.MyGrid
|
||||
}
|
||||
if q.MyCountry == "" {
|
||||
q.MyCountry = p.MyCountry
|
||||
}
|
||||
if q.MyState == "" {
|
||||
q.MyState = p.MyState
|
||||
}
|
||||
if q.MyCounty == "" {
|
||||
q.MyCounty = p.MyCounty
|
||||
}
|
||||
if q.MyStreet == "" {
|
||||
q.MyStreet = p.MyStreet
|
||||
}
|
||||
if q.MyCity == "" {
|
||||
q.MyCity = p.MyCity
|
||||
}
|
||||
if q.MyPostalCode == "" {
|
||||
q.MyPostalCode = p.MyPostalCode
|
||||
}
|
||||
if q.MySOTARef == "" {
|
||||
q.MySOTARef = p.MySOTARef
|
||||
}
|
||||
if q.MyPOTARef == "" {
|
||||
q.MyPOTARef = p.MyPOTARef
|
||||
}
|
||||
if q.MyRig == "" {
|
||||
q.MyRig = p.MyRig
|
||||
}
|
||||
if q.MyAntenna == "" {
|
||||
q.MyAntenna = p.MyAntenna
|
||||
}
|
||||
if q.TXPower == nil && p.TxPower != nil {
|
||||
v := *p.TxPower
|
||||
q.TXPower = &v
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ListQSO(f qso.ListFilter) ([]qso.QSO, error) {
|
||||
if a.qso == nil {
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.List(a.ctx, f)
|
||||
}
|
||||
|
||||
func (a *App) CountQSO() (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.Count(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) GetQSO(id int64) (qso.QSO, error) {
|
||||
if a.qso == nil {
|
||||
return qso.QSO{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.GetByID(a.ctx, id)
|
||||
}
|
||||
|
||||
func (a *App) UpdateQSO(q qso.QSO) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.Update(a.ctx, q)
|
||||
}
|
||||
|
||||
func (a *App) DeleteQSO(id int64) error {
|
||||
if a.qso == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
// WorkedBefore returns prior contacts with the given callsign at both
|
||||
// call and DXCC granularity. Pass dxccHint=0 when unknown — the function
|
||||
// will infer it from past QSOs with the same call when possible.
|
||||
func (a *App) WorkedBefore(callsign string, dxccHint int) (qso.WorkedBefore, error) {
|
||||
if a.qso == nil {
|
||||
return qso.WorkedBefore{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.WorkedBefore(a.ctx, callsign, dxccHint)
|
||||
}
|
||||
|
||||
// SetCompactMode toggles a tiny always-on-top window that exposes just the
|
||||
// QSO entry — useful when running on a single screen alongside WSJT-X,
|
||||
// JT-Alert or the cluster.
|
||||
//
|
||||
// We can't easily spawn a real second OS window in Wails v2, but a resized
|
||||
// always-on-top main window does the job from the user's perspective.
|
||||
// Sizes tuned so the compact entry strip fits in a single row (no wrap).
|
||||
// Min size must be reduced BEFORE resizing down, otherwise the OS clamps to
|
||||
// the previous (larger) min — and increased BEFORE resizing up.
|
||||
const (
|
||||
compactW, compactH = 980, 140
|
||||
normalW, normalH = 1400, 900
|
||||
normalMinW, normalMinH = 1100, 700
|
||||
)
|
||||
|
||||
func (a *App) SetCompactMode(on bool) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
if on {
|
||||
wruntime.WindowSetMinSize(a.ctx, compactW, compactH)
|
||||
wruntime.WindowSetSize(a.ctx, compactW, compactH)
|
||||
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
|
||||
} else {
|
||||
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
|
||||
wruntime.WindowSetMinSize(a.ctx, normalMinW, normalMinH)
|
||||
wruntime.WindowSetSize(a.ctx, normalW, normalH)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAllQSO wipes every QSO. Returns the number of rows removed.
|
||||
// The frontend MUST gate this behind a strong confirmation prompt.
|
||||
func (a *App) DeleteAllQSO() (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.DeleteAll(a.ctx)
|
||||
}
|
||||
|
||||
// --- ADIF bindings ---
|
||||
|
||||
func (a *App) OpenADIFFile() (string, error) {
|
||||
return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||
Title: "Import ADIF",
|
||||
Filters: []wruntime.FileFilter{
|
||||
{DisplayName: "ADIF files (*.adi, *.adif)", Pattern: "*.adi;*.adif"},
|
||||
{DisplayName: "All files (*.*)", Pattern: "*.*"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ImportADIF(path string) (adif.ImportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if path == "" {
|
||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||
}
|
||||
im := &adif.Importer{Repo: a.qso}
|
||||
return im.ImportFile(a.ctx, path)
|
||||
}
|
||||
|
||||
// --- Lookup bindings ---
|
||||
|
||||
// LookupCallsign returns the cached or freshly-fetched info for a callsign.
|
||||
// Errors are returned as-is to the frontend; ErrNotFound surfaces as
|
||||
// "callsign not found".
|
||||
func (a *App) LookupCallsign(callsign string) (lookup.Result, error) {
|
||||
if a.lookup == nil {
|
||||
return lookup.Result{}, fmt.Errorf("lookup not initialized")
|
||||
}
|
||||
r, err := a.lookup.Lookup(a.ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("callsign not found")
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// GetLookupSettings returns current credentials and cache TTL.
|
||||
func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||||
if a.settings == nil {
|
||||
return LookupSettings{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyQRZUser, keyQRZPassword, keyHQUser, keyHQPassword,
|
||||
keyCacheTTL, keyLookupPrimary, keyLookupFailsafe)
|
||||
if err != nil {
|
||||
return LookupSettings{}, err
|
||||
}
|
||||
ttl, _ := strconv.Atoi(m[keyCacheTTL])
|
||||
if ttl <= 0 {
|
||||
ttl = 30
|
||||
}
|
||||
return LookupSettings{
|
||||
QRZUser: m[keyQRZUser],
|
||||
QRZPassword: m[keyQRZPassword],
|
||||
HamQTHUser: m[keyHQUser],
|
||||
HamQTHPassword: m[keyHQPassword],
|
||||
Primary: m[keyLookupPrimary],
|
||||
Failsafe: m[keyLookupFailsafe],
|
||||
CacheTTLDays: ttl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveLookupSettings persists credentials and rebuilds the provider chain.
|
||||
func (a *App) SaveLookupSettings(s LookupSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.CacheTTLDays <= 0 {
|
||||
s.CacheTTLDays = 30
|
||||
}
|
||||
// Reject a primary == failsafe routing combo — would just hit the same
|
||||
// provider twice. Frontend should prevent this but defend in depth.
|
||||
if s.Primary != "" && s.Primary == s.Failsafe {
|
||||
s.Failsafe = ""
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyQRZUser: s.QRZUser,
|
||||
keyQRZPassword: s.QRZPassword,
|
||||
keyHQUser: s.HamQTHUser,
|
||||
keyHQPassword: s.HamQTHPassword,
|
||||
keyCacheTTL: strconv.Itoa(s.CacheTTLDays),
|
||||
keyLookupPrimary: s.Primary,
|
||||
keyLookupFailsafe: s.Failsafe,
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.reloadLookupProviders()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestLookupProvider runs a one-shot lookup against a specific provider so
|
||||
// the user can verify credentials before saving. callsign defaults to the
|
||||
// active profile's callsign when empty (handy "test against my own call").
|
||||
// Returns the result on success or a descriptive error.
|
||||
func (a *App) TestLookupProvider(name, callsign, user, password string) (lookup.Result, error) {
|
||||
if user == "" || password == "" {
|
||||
return lookup.Result{}, fmt.Errorf("user and password required")
|
||||
}
|
||||
if callsign == "" {
|
||||
if a.profiles != nil {
|
||||
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||
callsign = p.Callsign
|
||||
}
|
||||
}
|
||||
if callsign == "" {
|
||||
callsign = "W1AW" // ARRL HQ — always present in every database
|
||||
}
|
||||
}
|
||||
var p lookup.Provider
|
||||
switch name {
|
||||
case "qrz":
|
||||
p = lookup.NewQRZ(user, password)
|
||||
case "hamqth":
|
||||
p = lookup.NewHamQTH(user, password)
|
||||
default:
|
||||
return lookup.Result{}, fmt.Errorf("unknown provider %q", name)
|
||||
}
|
||||
r, err := p.Lookup(a.ctx, callsign)
|
||||
if errors.Is(err, lookup.ErrNotFound) {
|
||||
return lookup.Result{}, fmt.Errorf("%s reachable but %q not found (creds look OK)", name, callsign)
|
||||
}
|
||||
if err != nil {
|
||||
return lookup.Result{}, err
|
||||
}
|
||||
r.Source = name
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- CAT bindings ---
|
||||
|
||||
// GetCATSettings returns the stored CAT configuration (defaults applied).
|
||||
func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
out := CATSettings{
|
||||
Enabled: m[keyCATEnabled] == "1",
|
||||
Backend: m[keyCATBackend],
|
||||
OmniRigNum: 1,
|
||||
PollMs: 250,
|
||||
DelayMs: 0,
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
}
|
||||
if out.Backend == "" {
|
||||
out.Backend = "omnirig"
|
||||
}
|
||||
if out.DigitalDefault == "" {
|
||||
out.DigitalDefault = "FT8"
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATOmniRigNum]); n == 1 || n == 2 {
|
||||
out.OmniRigNum = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATPollMs]); n >= 50 && n <= 2000 {
|
||||
out.PollMs = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATDelayMs]); n >= 0 && n <= 500 {
|
||||
out.DelayMs = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveCATSettings persists CAT config and restarts the manager accordingly.
|
||||
func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Backend == "" {
|
||||
s.Backend = "omnirig"
|
||||
}
|
||||
if s.OmniRigNum != 1 && s.OmniRigNum != 2 {
|
||||
s.OmniRigNum = 1
|
||||
}
|
||||
if s.PollMs < 50 || s.PollMs > 2000 {
|
||||
s.PollMs = 250
|
||||
}
|
||||
if s.DelayMs < 0 || s.DelayMs > 500 {
|
||||
s.DelayMs = 0
|
||||
}
|
||||
enabled := "0"
|
||||
if s.Enabled {
|
||||
enabled = "1"
|
||||
}
|
||||
if s.DigitalDefault == "" {
|
||||
s.DigitalDefault = "FT8"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.reloadCAT()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCATState returns the current snapshot from the CAT manager. Used by the
|
||||
// frontend on mount before any cat:state event has been emitted.
|
||||
func (a *App) GetCATState() cat.RigState {
|
||||
if a.cat == nil {
|
||||
return cat.RigState{}
|
||||
}
|
||||
return a.cat.State()
|
||||
}
|
||||
|
||||
// SetCATFrequency lets the frontend push a freq to the rig (cluster click,
|
||||
// memory recall, …). Returns an error if CAT isn't running or the backend
|
||||
// refuses (out-of-range, etc.).
|
||||
func (a *App) SetCATFrequency(hz int64) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.SetFrequency(hz)
|
||||
}
|
||||
|
||||
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
||||
// translated to backend-specific values by the backend itself.
|
||||
func (a *App) SetCATMode(mode string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.SetMode(mode)
|
||||
}
|
||||
|
||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||
// requiring a trip through the full Settings panel. Persists the choice
|
||||
// so it survives restart.
|
||||
func (a *App) SwitchCATRig(n int) error {
|
||||
if n != 1 && n != 2 {
|
||||
return fmt.Errorf("rig num must be 1 or 2, got %d", n)
|
||||
}
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyCATOmniRigNum, strconv.Itoa(n)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.reloadCAT()
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadCAT (re)starts the CAT manager based on the current settings.
|
||||
// Called at startup and after the user saves new CAT config.
|
||||
func (a *App) reloadCAT() {
|
||||
if a.cat == nil {
|
||||
return
|
||||
}
|
||||
s, err := a.GetCATSettings()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||||
if !s.Enabled {
|
||||
a.cat.Stop()
|
||||
return
|
||||
}
|
||||
switch s.Backend {
|
||||
case "omnirig":
|
||||
// No explicit launch — COM auto-activates OmniRig.exe via its
|
||||
// LocalServer32 registration when we CreateObject in Connect().
|
||||
// Spawning OmniRig.exe ourselves (even with /Embedding) on every
|
||||
// reloadCAT raised the existing instance's window to the front,
|
||||
// which is what Log4OM avoids by relying entirely on COM activation.
|
||||
a.cat.Start(cat.NewOmniRig(s.OmniRigNum))
|
||||
default:
|
||||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||||
a.cat.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ClearLookupCache empties the local callsign cache.
|
||||
func (a *App) ClearLookupCache() error {
|
||||
if a.cache == nil {
|
||||
return fmt.Errorf("cache not initialized")
|
||||
}
|
||||
return a.cache.Clear(a.ctx)
|
||||
}
|
||||
|
||||
// CtyDatInfo describes the currently-loaded cty.dat file (or zero values
|
||||
// if it hasn't been loaded yet). Exposed for the Maintenance menu so the
|
||||
// user can see what they're working with before triggering a refresh.
|
||||
type CtyDatInfo struct {
|
||||
Path string `json:"path"`
|
||||
Entities int `json:"entities"`
|
||||
LoadedAt string `json:"loaded_at,omitempty"` // RFC3339, "" if not loaded
|
||||
FileModTime string `json:"file_mod_time,omitempty"` // RFC3339, "" if missing
|
||||
}
|
||||
|
||||
// GetCtyDatInfo returns metadata about the on-disk cty.dat.
|
||||
func (a *App) GetCtyDatInfo() CtyDatInfo {
|
||||
if a.dxcc == nil {
|
||||
return CtyDatInfo{}
|
||||
}
|
||||
src := a.dxcc.Info()
|
||||
out := CtyDatInfo{Path: src.Path, Entities: src.Entities}
|
||||
if !src.LoadedAt.IsZero() {
|
||||
out.LoadedAt = src.LoadedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !src.FileModTime.IsZero() {
|
||||
out.FileModTime = src.FileModTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RefreshCtyDat re-downloads cty.dat from country-files.com and reloads it
|
||||
// into memory. Synchronous so the UI can show a spinner; ~1s typical.
|
||||
func (a *App) RefreshCtyDat() (CtyDatInfo, error) {
|
||||
if a.dxcc == nil {
|
||||
return CtyDatInfo{}, fmt.Errorf("dxcc manager not initialized")
|
||||
}
|
||||
if err := a.dxcc.Refresh(a.ctx); err != nil {
|
||||
return CtyDatInfo{}, err
|
||||
}
|
||||
return a.GetCtyDatInfo(), nil
|
||||
}
|
||||
|
||||
// --- Station bindings ---
|
||||
//
|
||||
// GetStationSettings/SaveStationSettings now operate on the **currently
|
||||
// active profile** rather than a flat settings key set. Kept for the
|
||||
// existing topbar/quick-edit code paths; the full profile CRUD lives in
|
||||
// the Profile bindings below.
|
||||
|
||||
func (a *App) GetStationSettings() (StationSettings, error) {
|
||||
if a.profiles == nil {
|
||||
return StationSettings{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return StationSettings{}, err
|
||||
}
|
||||
return StationSettings{
|
||||
Callsign: p.Callsign,
|
||||
Operator: p.Operator,
|
||||
MyGrid: p.MyGrid,
|
||||
MyCountry: p.MyCountry,
|
||||
MySOTARef: p.MySOTARef,
|
||||
MyPOTARef: p.MyPOTARef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Lists bindings (bands + modes with default RST) ---
|
||||
|
||||
// GetListsSettings returns the user-customisable lists. Defaults are
|
||||
// returned when the user has not customised anything.
|
||||
func (a *App) GetListsSettings() (ListsSettings, error) {
|
||||
if a.settings == nil {
|
||||
return ListsSettings{Bands: defaultBands, Modes: defaultModes}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
out := ListsSettings{}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsBands); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.Bands)
|
||||
}
|
||||
if raw, _ := a.settings.Get(a.ctx, keyListsModes); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &out.Modes)
|
||||
}
|
||||
if len(out.Bands) == 0 {
|
||||
out.Bands = append([]string(nil), defaultBands...)
|
||||
}
|
||||
if len(out.Modes) == 0 {
|
||||
out.Modes = append([]ModePreset(nil), defaultModes...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveListsSettings persists the user-customised lists.
|
||||
func (a *App) SaveListsSettings(l ListsSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
b, err := json.Marshal(l.Bands)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyListsBands, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := json.Marshal(l.Modes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyListsModes, string(m))
|
||||
}
|
||||
|
||||
// SaveStationSettings updates only the six "basic" fields on the active
|
||||
// profile. Use the Profile bindings (ListProfiles / SaveProfile…) for
|
||||
// full multi-profile management.
|
||||
func (a *App) SaveStationSettings(s StationSettings) error {
|
||||
if a.profiles == nil {
|
||||
return fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
p, err := a.profiles.Active(a.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Callsign = s.Callsign
|
||||
p.Operator = s.Operator
|
||||
p.MyGrid = s.MyGrid
|
||||
p.MyCountry = s.MyCountry
|
||||
p.MySOTARef = s.MySOTARef
|
||||
p.MyPOTARef = s.MyPOTARef
|
||||
return a.profiles.Save(a.ctx, &p)
|
||||
}
|
||||
|
||||
// --- Profile bindings (multi-profile CRUD) ---
|
||||
|
||||
// ListProfiles returns every saved profile, active first.
|
||||
func (a *App) ListProfiles() ([]profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return nil, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.List(a.ctx)
|
||||
}
|
||||
|
||||
// GetActiveProfile returns the currently-selected profile.
|
||||
func (a *App) GetActiveProfile() (profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.Active(a.ctx)
|
||||
}
|
||||
|
||||
// SaveProfile upserts a profile. Pass id=0 to create a new one.
|
||||
func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||
return profile.Profile{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteProfile removes a profile. Refuses to delete the last remaining
|
||||
// profile; promotes another to active if the deleted one was selected.
|
||||
func (a *App) DeleteProfile(id int64) error {
|
||||
if a.profiles == nil {
|
||||
return fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
// ActivateProfile switches the selected profile. Subsequent QSOs stamp
|
||||
// MY_* fields from this one.
|
||||
func (a *App) ActivateProfile(id int64) error {
|
||||
if a.profiles == nil {
|
||||
return fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.SetActive(a.ctx, id)
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
// the user has a "Home" profile and wants to derive "Portable" from it
|
||||
// without retyping every field.
|
||||
func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error) {
|
||||
if a.profiles == nil {
|
||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||
}
|
||||
return a.profiles.Duplicate(a.ctx, id, newName)
|
||||
}
|
||||
Reference in New Issue
Block a user