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,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go get *)",
|
||||||
|
"Bash(go build *)",
|
||||||
|
"Bash(wails generate *)",
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# --- Build artifacts ---
|
||||||
|
build/bin/
|
||||||
|
frontend/dist/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# --- Dependencies / generated ---
|
||||||
|
node_modules/
|
||||||
|
# wailsjs/ is intentionally tracked — generated by `wails generate module`
|
||||||
|
# but consumed by the frontend at build time, so committing it spares
|
||||||
|
# fresh clones from a mandatory generate step.
|
||||||
|
|
||||||
|
# --- Go ---
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# --- IDE / editors ---
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# --- OS noise ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# --- Local user data (lives in %APPDATA%/HamLog, but safety net) ---
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
hamlog.db*
|
||||||
|
cty.dat
|
||||||
|
cat.log
|
||||||
|
|
||||||
|
# --- Secrets ---
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Build Directory
|
||||||
|
|
||||||
|
The build directory is used to house all the build files and assets for your application.
|
||||||
|
|
||||||
|
The structure is:
|
||||||
|
|
||||||
|
* bin - Output directory
|
||||||
|
* darwin - macOS specific files
|
||||||
|
* windows - Windows specific files
|
||||||
|
|
||||||
|
## Mac
|
||||||
|
|
||||||
|
The `darwin` directory holds files specific to Mac builds.
|
||||||
|
These may be customised and used as part of the build. To return these files to the default state, simply delete them
|
||||||
|
and
|
||||||
|
build with `wails build`.
|
||||||
|
|
||||||
|
The directory contains the following files:
|
||||||
|
|
||||||
|
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
|
||||||
|
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
The `windows` directory contains the manifest and rc files used when building with `wails build`.
|
||||||
|
These may be customised for your application. To return these files to the default state, simply delete them and
|
||||||
|
build with `wails build`.
|
||||||
|
|
||||||
|
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
|
||||||
|
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
|
||||||
|
will be created using the `appicon.png` file in the build directory.
|
||||||
|
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
|
||||||
|
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
|
||||||
|
as well as the application itself (right click the exe -> properties -> details)
|
||||||
|
- `wails.exe.manifest` - The main application manifest file.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "{{.Info.ProductVersion}}"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "{{.Info.ProductVersion}}",
|
||||||
|
"CompanyName": "{{.Info.CompanyName}}",
|
||||||
|
"FileDescription": "{{.Info.ProductName}}",
|
||||||
|
"LegalCopyright": "{{.Info.Copyright}}",
|
||||||
|
"ProductName": "{{.Info.ProductName}}",
|
||||||
|
"Comments": "{{.Info.Comments}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
Unicode true
|
||||||
|
|
||||||
|
####
|
||||||
|
## Please note: Template replacements don't work in this file. They are provided with default defines like
|
||||||
|
## mentioned underneath.
|
||||||
|
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
|
||||||
|
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
|
||||||
|
## from outside of Wails for debugging and development of the installer.
|
||||||
|
##
|
||||||
|
## For development first make a wails nsis build to populate the "wails_tools.nsh":
|
||||||
|
## > wails build --target windows/amd64 --nsis
|
||||||
|
## Then you can call makensis on this file with specifying the path to your binary:
|
||||||
|
## For a AMD64 only installer:
|
||||||
|
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
|
||||||
|
## For a ARM64 only installer:
|
||||||
|
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
|
||||||
|
## For a installer with both architectures:
|
||||||
|
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
|
||||||
|
####
|
||||||
|
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
|
||||||
|
####
|
||||||
|
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
|
||||||
|
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
|
||||||
|
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
|
||||||
|
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
|
||||||
|
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
|
||||||
|
###
|
||||||
|
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
|
||||||
|
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||||
|
####
|
||||||
|
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||||
|
####
|
||||||
|
## Include the wails tools
|
||||||
|
####
|
||||||
|
!include "wails_tools.nsh"
|
||||||
|
|
||||||
|
# The version information for this two must consist of 4 parts
|
||||||
|
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||||
|
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||||
|
|
||||||
|
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||||
|
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||||
|
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||||
|
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||||
|
|
||||||
|
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||||
|
ManifestDPIAware true
|
||||||
|
|
||||||
|
!include "MUI.nsh"
|
||||||
|
|
||||||
|
!define MUI_ICON "..\icon.ico"
|
||||||
|
!define MUI_UNICON "..\icon.ico"
|
||||||
|
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||||
|
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||||
|
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||||
|
|
||||||
|
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||||
|
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||||
|
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||||
|
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||||
|
|
||||||
|
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
|
||||||
|
|
||||||
|
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
|
||||||
|
#!uninstfinalize 'signtool --file "%1"'
|
||||||
|
#!finalize 'signtool --file "%1"'
|
||||||
|
|
||||||
|
Name "${INFO_PRODUCTNAME}"
|
||||||
|
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||||
|
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
|
||||||
|
ShowInstDetails show # This will always show the installation details.
|
||||||
|
|
||||||
|
Function .onInit
|
||||||
|
!insertmacro wails.checkArchitecture
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Section
|
||||||
|
!insertmacro wails.setShellContext
|
||||||
|
|
||||||
|
!insertmacro wails.webview2runtime
|
||||||
|
|
||||||
|
SetOutPath $INSTDIR
|
||||||
|
|
||||||
|
!insertmacro wails.files
|
||||||
|
|
||||||
|
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
|
||||||
|
!insertmacro wails.associateFiles
|
||||||
|
!insertmacro wails.associateCustomProtocols
|
||||||
|
|
||||||
|
!insertmacro wails.writeUninstaller
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "uninstall"
|
||||||
|
!insertmacro wails.setShellContext
|
||||||
|
|
||||||
|
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||||
|
|
||||||
|
RMDir /r $INSTDIR
|
||||||
|
|
||||||
|
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||||
|
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||||
|
|
||||||
|
!insertmacro wails.unassociateFiles
|
||||||
|
!insertmacro wails.unassociateCustomProtocols
|
||||||
|
|
||||||
|
!insertmacro wails.deleteUninstaller
|
||||||
|
SectionEnd
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# DO NOT EDIT - Generated automatically by `wails build`
|
||||||
|
|
||||||
|
!include "x64.nsh"
|
||||||
|
!include "WinVer.nsh"
|
||||||
|
!include "FileFunc.nsh"
|
||||||
|
|
||||||
|
!ifndef INFO_PROJECTNAME
|
||||||
|
!define INFO_PROJECTNAME "{{.Name}}"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_COMPANYNAME
|
||||||
|
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_PRODUCTNAME
|
||||||
|
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_PRODUCTVERSION
|
||||||
|
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
|
||||||
|
!endif
|
||||||
|
!ifndef INFO_COPYRIGHT
|
||||||
|
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
|
||||||
|
!endif
|
||||||
|
!ifndef PRODUCT_EXECUTABLE
|
||||||
|
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
|
||||||
|
!endif
|
||||||
|
!ifndef UNINST_KEY_NAME
|
||||||
|
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
|
||||||
|
!endif
|
||||||
|
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
|
||||||
|
|
||||||
|
!ifndef REQUEST_EXECUTION_LEVEL
|
||||||
|
!define REQUEST_EXECUTION_LEVEL "admin"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
|
||||||
|
|
||||||
|
!ifdef ARG_WAILS_AMD64_BINARY
|
||||||
|
!define SUPPORTS_AMD64
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef ARG_WAILS_ARM64_BINARY
|
||||||
|
!define SUPPORTS_ARM64
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
!define ARCH "amd64_arm64"
|
||||||
|
!else
|
||||||
|
!define ARCH "amd64"
|
||||||
|
!endif
|
||||||
|
!else
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
!define ARCH "arm64"
|
||||||
|
!else
|
||||||
|
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
|
||||||
|
!endif
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!macro wails.checkArchitecture
|
||||||
|
!ifndef WAILS_WIN10_REQUIRED
|
||||||
|
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
|
||||||
|
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
${If} ${AtLeastWin10}
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
${if} ${IsNativeAMD64}
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
${if} ${IsNativeARM64}
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
IfSilent silentArch notSilentArch
|
||||||
|
silentArch:
|
||||||
|
SetErrorLevel 65
|
||||||
|
Abort
|
||||||
|
notSilentArch:
|
||||||
|
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
|
||||||
|
Quit
|
||||||
|
${else}
|
||||||
|
IfSilent silentWin notSilentWin
|
||||||
|
silentWin:
|
||||||
|
SetErrorLevel 64
|
||||||
|
Abort
|
||||||
|
notSilentWin:
|
||||||
|
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
|
||||||
|
Quit
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.files
|
||||||
|
!ifdef SUPPORTS_AMD64
|
||||||
|
${if} ${IsNativeAMD64}
|
||||||
|
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
|
||||||
|
!ifdef SUPPORTS_ARM64
|
||||||
|
${if} ${IsNativeARM64}
|
||||||
|
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
|
||||||
|
${EndIf}
|
||||||
|
!endif
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.writeUninstaller
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||||
|
|
||||||
|
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||||
|
IntFmt $0 "0x%08X" $0
|
||||||
|
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.deleteUninstaller
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.setShellContext
|
||||||
|
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
|
||||||
|
SetShellVarContext all
|
||||||
|
${else}
|
||||||
|
SetShellVarContext current
|
||||||
|
${EndIf}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
# Install webview2 by launching the bootstrapper
|
||||||
|
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
|
||||||
|
!macro wails.webview2runtime
|
||||||
|
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
|
||||||
|
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
SetRegView 64
|
||||||
|
# If the admin key exists and is not empty then webview2 is already installed
|
||||||
|
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
|
||||||
|
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
|
||||||
|
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto ok
|
||||||
|
${EndIf}
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
|
||||||
|
SetDetailsPrint listonly
|
||||||
|
|
||||||
|
InitPluginsDir
|
||||||
|
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||||
|
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||||
|
File "tmp\MicrosoftEdgeWebview2Setup.exe"
|
||||||
|
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
|
||||||
|
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
|
||||||
|
; Backup the previously associated file class
|
||||||
|
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
|
||||||
|
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
|
||||||
|
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro APP_UNASSOCIATE EXT FILECLASS
|
||||||
|
; Backup the previously associated file class
|
||||||
|
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
|
||||||
|
|
||||||
|
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.associateFiles
|
||||||
|
; Create file associations
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||||
|
|
||||||
|
File "..\{{.IconName}}.ico"
|
||||||
|
{{end}}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.unassociateFiles
|
||||||
|
; Delete app associations
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
|
||||||
|
|
||||||
|
Delete "$INSTDIR\{{.IconName}}.ico"
|
||||||
|
{{end}}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
|
||||||
|
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
|
||||||
|
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
|
||||||
|
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.associateCustomProtocols
|
||||||
|
; Create custom protocols associations
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro wails.unassociateCustomProtocols
|
||||||
|
; Delete app custom protocol associations
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
|
||||||
|
{{end}}
|
||||||
|
!macroend
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
<asmv3:application>
|
||||||
|
<asmv3:windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
|
||||||
|
</asmv3:windowsSettings>
|
||||||
|
</asmv3:application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// dbdiag inspects HamLog DB state. Optional 2nd arg: "wb CALL DXCC".
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrationsDir = "internal/db/migrations"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
base, _ := os.UserConfigDir()
|
||||||
|
dbPath := filepath.Join(base, "HamLog", "hamlog.db")
|
||||||
|
|
||||||
|
mode := "summary"
|
||||||
|
var call string
|
||||||
|
var dxcc int
|
||||||
|
if len(os.Args) >= 3 && os.Args[1] == "wb" {
|
||||||
|
mode = "wb"
|
||||||
|
call = strings.ToUpper(os.Args[2])
|
||||||
|
if len(os.Args) >= 4 {
|
||||||
|
dxcc, _ = strconv.Atoi(os.Args[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("DB path:", dbPath)
|
||||||
|
dsn := "file:" + dbPath + "?_pragma=foreign_keys(on)"
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
must("open", err)
|
||||||
|
defer conn.Close()
|
||||||
|
must("ping", conn.Ping())
|
||||||
|
|
||||||
|
if mode == "wb" {
|
||||||
|
probeWB(conn, call, dxcc)
|
||||||
|
probeCache(conn, call)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeCache(conn *sql.DB, call string) {
|
||||||
|
fmt.Println("\nCache row for", call, ":")
|
||||||
|
row := conn.QueryRow(`SELECT name, qth, country, grid, dxcc, source, fetched_at
|
||||||
|
FROM callsign_cache WHERE callsign = ?`, call)
|
||||||
|
var name, qth, country, grid, src, fetched sql.NullString
|
||||||
|
var dxcc sql.NullInt64
|
||||||
|
if err := row.Scan(&name, &qth, &country, &grid, &dxcc, &src, &fetched); err != nil {
|
||||||
|
fmt.Println(" (no cache row:", err, ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" name=%q qth=%q country=%q grid=%q\n", name.String, qth.String, country.String, grid.String)
|
||||||
|
fmt.Printf(" dxcc=%v(valid=%v) source=%s fetched_at=%s\n", dxcc.Int64, dxcc.Valid, src.String, fetched.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeWB(conn *sql.DB, call string, dxcc int) {
|
||||||
|
fmt.Printf("\nProbing WorkedBefore for call=%q dxcc=%d\n", call, dxcc)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
must("count call", conn.QueryRow(`SELECT COUNT(*) FROM qso WHERE callsign = ?`, call).Scan(&count))
|
||||||
|
fmt.Printf(" count(callsign=%s) = %d\n", call, count)
|
||||||
|
|
||||||
|
if dxcc == 0 {
|
||||||
|
var d sql.NullInt64
|
||||||
|
_ = conn.QueryRow(`SELECT dxcc FROM qso
|
||||||
|
WHERE callsign = ? AND dxcc IS NOT NULL
|
||||||
|
ORDER BY qso_date DESC LIMIT 1`, call).Scan(&d)
|
||||||
|
if d.Valid {
|
||||||
|
dxcc = int(d.Int64)
|
||||||
|
fmt.Printf(" inferred dxcc from prior QSO: %d\n", dxcc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dxcc == 0 {
|
||||||
|
fmt.Println(" no DXCC available — skipping entity stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var dxccCount int
|
||||||
|
must("count dxcc", conn.QueryRow(`SELECT COUNT(*) FROM qso WHERE dxcc = ?`, dxcc).Scan(&dxccCount))
|
||||||
|
fmt.Printf(" count(dxcc=%d) = %d\n", dxcc, dxccCount)
|
||||||
|
|
||||||
|
// Distinct (band, mode) for this DXCC
|
||||||
|
rows, err := conn.Query(`SELECT band, mode, COUNT(*) FROM qso WHERE dxcc = ? GROUP BY band, mode ORDER BY band, mode`, dxcc)
|
||||||
|
must("group by band/mode", err)
|
||||||
|
defer rows.Close()
|
||||||
|
fmt.Printf(" distinct (band, mode) groups for dxcc=%d:\n", dxcc)
|
||||||
|
var n int
|
||||||
|
for rows.Next() {
|
||||||
|
var band, mode string
|
||||||
|
var c int
|
||||||
|
rows.Scan(&band, &mode, &c)
|
||||||
|
fmt.Printf(" %-8s %-12s %d QSOs\n", band, mode, c)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
fmt.Println(" (none)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same query as backend
|
||||||
|
fmt.Println("\n --- exact backend query ---")
|
||||||
|
rows2, err := conn.Query(`
|
||||||
|
SELECT band, mode,
|
||||||
|
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
|
||||||
|
MAX(CASE WHEN callsign = ?
|
||||||
|
AND (lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y')
|
||||||
|
THEN 1 ELSE 0 END),
|
||||||
|
MAX(CASE WHEN lotw_rcvd = 'Y' OR qsl_rcvd = 'Y' OR eqsl_rcvd = 'Y'
|
||||||
|
THEN 1 ELSE 0 END)
|
||||||
|
FROM qso WHERE dxcc = ?
|
||||||
|
GROUP BY band, mode
|
||||||
|
ORDER BY band, mode`, call, call, dxcc)
|
||||||
|
must("backend query", err)
|
||||||
|
defer rows2.Close()
|
||||||
|
n = 0
|
||||||
|
for rows2.Next() {
|
||||||
|
var band, mode string
|
||||||
|
var cw, cc, dc int
|
||||||
|
rows2.Scan(&band, &mode, &cw, &cc, &dc)
|
||||||
|
fmt.Printf(" %-8s %-12s callW=%d callC=%d dxccC=%d\n", band, mode, cw, cc, dc)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
fmt.Printf(" → %d rows\n", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func summary(conn *sql.DB) {
|
||||||
|
if st, err := os.Stat(filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir("a")))), "")); err == nil {
|
||||||
|
fmt.Printf("(size info skipped) %v\n", st)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Applied migrations:")
|
||||||
|
rows, err := conn.Query(`SELECT name FROM schema_migrations ORDER BY name`)
|
||||||
|
if err == nil {
|
||||||
|
for rows.Next() {
|
||||||
|
var n string
|
||||||
|
rows.Scan(&n)
|
||||||
|
fmt.Println(" -", n)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n)
|
||||||
|
fmt.Println("\nQSO rows:", n)
|
||||||
|
|
||||||
|
var withDxcc int64
|
||||||
|
conn.QueryRow(`SELECT COUNT(*) FROM qso WHERE dxcc IS NOT NULL`).Scan(&withDxcc)
|
||||||
|
fmt.Printf("Rows with non-null dxcc: %d (%.1f%%)\n", withDxcc, float64(withDxcc)/float64(n)*100)
|
||||||
|
|
||||||
|
var nDistinctDXCC int
|
||||||
|
conn.QueryRow(`SELECT COUNT(DISTINCT dxcc) FROM qso WHERE dxcc IS NOT NULL`).Scan(&nDistinctDXCC)
|
||||||
|
fmt.Println("Distinct DXCC entities in log:", nDistinctDXCC)
|
||||||
|
|
||||||
|
fmt.Println("\nTry: go run ./cmd/dbdiag wb 4X6TT")
|
||||||
|
fmt.Println(" go run ./cmd/dbdiag wb 4X6TT 336")
|
||||||
|
_ = context.Background
|
||||||
|
_ = sort.Strings
|
||||||
|
must("dummy", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(label string, err error) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, label+":", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<title>HamLog</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="./src/main.tsx" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
Generated
+3774
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@types/node": "^25.9.1",
|
||||||
|
"@types/react": "^18.0.17",
|
||||||
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
58f02c99f9fceb8f5aeae2c8b90fd325
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Star, Radio } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
|
type WorkedBefore = WorkedBeforeView;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wb: WorkedBefore | null;
|
||||||
|
busy: boolean;
|
||||||
|
currentBand: string;
|
||||||
|
currentMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13-column band layout — no 4m, no SHF (per user preference).
|
||||||
|
const BANDS: { tag: string; label: string }[] = [
|
||||||
|
{ tag: '160m', label: '160' },
|
||||||
|
{ tag: '80m', label: '80' },
|
||||||
|
{ tag: '60m', label: '60' },
|
||||||
|
{ tag: '40m', label: '40' },
|
||||||
|
{ tag: '30m', label: '30' },
|
||||||
|
{ tag: '20m', label: '20' },
|
||||||
|
{ tag: '17m', label: '17' },
|
||||||
|
{ tag: '15m', label: '15' },
|
||||||
|
{ tag: '12m', label: '12' },
|
||||||
|
{ tag: '10m', label: '10' },
|
||||||
|
{ tag: '6m', label: '6' },
|
||||||
|
{ tag: '2m', label: 'V' },
|
||||||
|
{ tag: '70cm', label: 'U' },
|
||||||
|
];
|
||||||
|
const CLASSES = ['PH', 'CW', 'DIG'] as const;
|
||||||
|
|
||||||
|
const PHONE_MODES = new Set(['SSB','USB','LSB','AM','FM','DIGITALVOICE','PHONE']);
|
||||||
|
function classMatchesMode(cls: string, mode: string): boolean {
|
||||||
|
const u = (mode || '').toUpperCase();
|
||||||
|
if (cls === 'PH') return PHONE_MODES.has(u);
|
||||||
|
if (cls === 'CW') return u === 'CW';
|
||||||
|
return u !== '' && u !== 'CW' && !PHONE_MODES.has(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CLASSES: Record<string, string> = {
|
||||||
|
call_c: 'bg-emerald-700 hover:bg-emerald-600',
|
||||||
|
call_w: 'bg-emerald-300 hover:bg-emerald-200',
|
||||||
|
dxcc_c: 'bg-indigo-800 hover:bg-indigo-700',
|
||||||
|
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
||||||
|
const desc =
|
||||||
|
status === 'call_c' ? 'This callsign confirmed' :
|
||||||
|
status === 'call_w' ? 'This callsign worked (not confirmed)' :
|
||||||
|
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
|
||||||
|
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
|
||||||
|
'Never worked';
|
||||||
|
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
|
||||||
|
const dxcc = wb?.dxcc ?? 0;
|
||||||
|
const dxccName = wb?.dxcc_name ?? '';
|
||||||
|
const dxccCount = wb?.dxcc_count ?? 0;
|
||||||
|
const hasDxcc = dxcc > 0;
|
||||||
|
const newOne = hasDxcc && dxccCount === 0;
|
||||||
|
|
||||||
|
const statusMap = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const s of wb?.band_status ?? []) {
|
||||||
|
m.set(`${s.band}|${s.class}`, s.status);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [wb]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0',
|
||||||
|
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-[220px]">
|
||||||
|
{newOne ? (
|
||||||
|
<>
|
||||||
|
<Badge className="bg-amber-800 text-amber-50 gap-1 px-3 py-1 text-[11px]">
|
||||||
|
<Star className="size-3 fill-current" />
|
||||||
|
NEW ONE
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-amber-900">
|
||||||
|
<strong className="text-amber-950 font-semibold">{dxccName || `DXCC #${dxcc}`}</strong>
|
||||||
|
{' '}· never worked this entity
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : hasDxcc ? (
|
||||||
|
<>
|
||||||
|
<Badge className="bg-primary text-primary-foreground px-3 py-1 text-xs normal-case font-semibold tracking-normal">
|
||||||
|
{dxccName || `DXCC #${dxcc}`}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
|
||||||
|
QSO{dxccCount > 1 ? 's' : ''} with this entity
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : busy ? (
|
||||||
|
<span className="flex items-center gap-2 text-xs text-muted-foreground italic">
|
||||||
|
<Radio className="size-3.5 animate-pulse" />
|
||||||
|
looking up…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground italic">
|
||||||
|
Type a callsign to see entity stats
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="border-separate" style={{ borderSpacing: 2 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="w-[22px]" />
|
||||||
|
{BANDS.map((b) => (
|
||||||
|
<th
|
||||||
|
key={b.tag}
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-[10px] font-semibold px-1 text-center',
|
||||||
|
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{CLASSES.map((cls) => {
|
||||||
|
const classCurrent = classMatchesMode(cls, currentMode);
|
||||||
|
return (
|
||||||
|
<tr key={cls}>
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-[10px] font-semibold pr-1.5 text-right w-[22px]',
|
||||||
|
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cls}
|
||||||
|
</th>
|
||||||
|
{BANDS.map((b) => {
|
||||||
|
const st = statusMap.get(`${b.tag}|${cls}`) ?? '';
|
||||||
|
const isCurrent = b.tag === currentBand && classCurrent;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={b.tag}
|
||||||
|
title={cellTitle(b.tag, cls, st, isCurrent)}
|
||||||
|
className={cn(
|
||||||
|
'w-[22px] h-[18px] rounded transition-colors p-0',
|
||||||
|
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
|
||||||
|
isCurrent && 'ring-2 ring-amber-500 ring-inset',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
|
type WorkedBefore = WorkedBeforeView;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
wb: WorkedBefore | null;
|
||||||
|
busy: boolean;
|
||||||
|
currentCall: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(s: any): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
|
}
|
||||||
|
function fmtDateTime(s: any): string {
|
||||||
|
if (!s) return '';
|
||||||
|
const d = new Date(s);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallHistoryPanel({ wb, busy, currentCall }: Props) {
|
||||||
|
const hasCall = currentCall.trim() !== '';
|
||||||
|
const count = wb?.count ?? 0;
|
||||||
|
const entries = wb?.entries ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex flex-col bg-card border border-border rounded-lg shadow-sm min-h-0 overflow-hidden">
|
||||||
|
<header className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/40">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Worked before
|
||||||
|
</span>
|
||||||
|
{hasCall && (
|
||||||
|
<span className="font-mono text-sm font-bold text-primary tracking-wider">
|
||||||
|
{currentCall.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasCall && count > 0 && (
|
||||||
|
<Badge variant="accent" className="font-mono text-[11px] tracking-wider">
|
||||||
|
{count}×
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!hasCall ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||||
|
Type a callsign to see prior contacts.
|
||||||
|
</div>
|
||||||
|
) : busy && count === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground italic">
|
||||||
|
checking…
|
||||||
|
</div>
|
||||||
|
) : count === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
|
||||||
|
<Star className="size-6 text-primary fill-current" />
|
||||||
|
<div className="text-lg font-bold text-primary tracking-wider">NEW</div>
|
||||||
|
<div>No prior QSO with this callsign.</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1.5 text-[11px] text-muted-foreground bg-muted/40 border-b border-border">
|
||||||
|
First: <strong className="text-foreground font-semibold">{fmtDate(wb?.first)}</strong> ·{' '}
|
||||||
|
Last: <strong className="text-foreground font-semibold">{fmtDate(wb?.last)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<table className="w-full text-[11px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Date UTC</th>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Band</th>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">Mode</th>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST tx</th>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">RST rx</th>
|
||||||
|
<th className="sticky top-0 bg-stone-200 text-left px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground border-b border-border">QSL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="hover:bg-stone-50">
|
||||||
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{fmtDateTime(e.qso_date)}</td>
|
||||||
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-accent text-accent-foreground">{e.band}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap">
|
||||||
|
<span className="inline-block px-1.5 py-0.5 rounded font-mono text-[10px] font-semibold bg-emerald-100 text-emerald-700">{e.mode}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_sent ?? ''}</td>
|
||||||
|
<td className="px-2 py-1 font-mono border-b border-border/40 whitespace-nowrap">{e.rst_rcvd ?? ''}</td>
|
||||||
|
<td className="px-2 py-1 border-b border-border/40 whitespace-nowrap text-muted-foreground">
|
||||||
|
{e.lotw_rcvd === 'Y' && (
|
||||||
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-blue-600 mr-0.5" title="LoTW rcvd">L</span>
|
||||||
|
)}
|
||||||
|
{e.qsl_rcvd === 'Y' && (
|
||||||
|
<span className="inline-block w-[14px] h-[14px] rounded text-center leading-[14px] text-[9px] font-bold text-white bg-emerald-600 mr-0.5" title="Bureau rcvd">B</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{count > entries.length && (
|
||||||
|
<div className="text-center py-1.5 text-[11px] italic text-muted-foreground">
|
||||||
|
+ {count - entries.length} older QSOs
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogFooter,
|
||||||
|
AlertDialogTitle, AlertDialogDescription,
|
||||||
|
AlertDialogAction, AlertDialogCancel,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
/** When set, user must type this exact phrase before the confirm button enables. */
|
||||||
|
confirmPhrase?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
title = 'Confirm',
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
danger = false,
|
||||||
|
confirmPhrase = '',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const enabled = confirmPhrase === '' || typed === confirmPhrase;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||||
|
<AlertDialogContent
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && enabled) onConfirm(); }}
|
||||||
|
>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{message}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{confirmPhrase && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cd-input" className="text-xs text-foreground">
|
||||||
|
Type{' '}
|
||||||
|
<code className="bg-destructive/10 text-destructive font-mono font-bold px-1.5 py-0.5 rounded">
|
||||||
|
{confirmPhrase}
|
||||||
|
</code>{' '}
|
||||||
|
to confirm:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cd-input"
|
||||||
|
autoFocus
|
||||||
|
value={typed}
|
||||||
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={!enabled}
|
||||||
|
className={cn(
|
||||||
|
danger && buttonVariants({ variant: 'destructive' }),
|
||||||
|
!enabled && 'opacity-50 pointer-events-none',
|
||||||
|
)}
|
||||||
|
onClick={() => { if (enabled) onConfirm(); }}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronUp, Construction } from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { pathBetween } from '@/lib/maidenhead';
|
||||||
|
|
||||||
|
export interface DetailsState {
|
||||||
|
state: string;
|
||||||
|
cnty: string;
|
||||||
|
address: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
// DXCC entity number + zones (filled from QRZ/HamQTH or cty.dat fallback).
|
||||||
|
// Editable so the operator can correct an obviously wrong auto-fill.
|
||||||
|
dxcc?: number;
|
||||||
|
cqz?: number;
|
||||||
|
ituz?: number;
|
||||||
|
cont: string;
|
||||||
|
qsl_msg: string;
|
||||||
|
qsl_via: string;
|
||||||
|
ant_az?: number;
|
||||||
|
ant_el?: number;
|
||||||
|
ant_path: string;
|
||||||
|
prop_mode: string;
|
||||||
|
my_rig: string;
|
||||||
|
my_antenna: string;
|
||||||
|
tx_pwr?: number;
|
||||||
|
sat_name: string;
|
||||||
|
sat_mode: string;
|
||||||
|
contest_id: string;
|
||||||
|
srx?: number;
|
||||||
|
stx?: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
callsign: string;
|
||||||
|
prefix: string;
|
||||||
|
operatorGrid: string; // station.my_grid — origin for bearing/distance
|
||||||
|
remoteGrid: string; // entry-strip Grid value — destination
|
||||||
|
details: DetailsState;
|
||||||
|
onChange: (patch: Partial<DetailsState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabName = 'info' | 'awards' | 'my' | 'extended';
|
||||||
|
|
||||||
|
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
|
function numOrUndef(v: string): number | undefined {
|
||||||
|
if (v === '') return undefined;
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact field helper to keep the JSX dense.
|
||||||
|
function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
||||||
|
<Label className="mb-1">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange }: Props) {
|
||||||
|
const [open, setOpen] = useState<TabName | null>(null);
|
||||||
|
|
||||||
|
// Bearing/distance from operator's home grid to the remote station.
|
||||||
|
// Recomputed only when either grid actually changes.
|
||||||
|
const path = useMemo(
|
||||||
|
() => pathBetween(operatorGrid, remoteGrid),
|
||||||
|
[operatorGrid, remoteGrid],
|
||||||
|
);
|
||||||
|
const fmtDeg = (n: number) => `${Math.round(n)}°`;
|
||||||
|
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
|
||||||
|
|
||||||
|
function toggle(t: TabName) { setOpen((prev) => (prev === t ? null : t)); }
|
||||||
|
|
||||||
|
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
|
||||||
|
function setSatellite(on: boolean) {
|
||||||
|
if (on) {
|
||||||
|
if (details.prop_mode !== 'SAT') onChange({ prop_mode: 'SAT' });
|
||||||
|
} else {
|
||||||
|
onChange({
|
||||||
|
sat_name: '', sat_mode: '',
|
||||||
|
...(details.prop_mode === 'SAT' ? { prop_mode: '' } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: TabName; label: string }[] = [
|
||||||
|
{ key: 'info', label: 'Info (F2)' },
|
||||||
|
{ key: 'awards', label: 'Awards (F3)' },
|
||||||
|
{ key: 'my', label: 'My (F4)' },
|
||||||
|
{ key: 'extended', label: 'Extended (F5)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b border-border bg-card shrink-0">
|
||||||
|
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => toggle(t.key)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs font-medium border-b-2 border-transparent -mb-px transition-colors',
|
||||||
|
open === t.key
|
||||||
|
? 'text-primary border-primary font-semibold'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{open && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(null)}
|
||||||
|
className="ml-auto text-muted-foreground hover:text-foreground p-1.5"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{open === 'info' && (
|
||||||
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
|
<Field label="State / pref">
|
||||||
|
<Input value={details.state} onChange={(e) => onChange({ state: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="County">
|
||||||
|
<Input value={details.cnty} onChange={(e) => onChange({ cnty: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Prefix">
|
||||||
|
<Input className="font-mono uppercase" value={prefix} readOnly tabIndex={-1} />
|
||||||
|
</Field>
|
||||||
|
{/* DXCC #, CQ zone, ITU zone, Continent and Azimuth SP live in the
|
||||||
|
main entry strip — visible without opening F2. F2 keeps the
|
||||||
|
less-needed long-path bearing and both distances. */}
|
||||||
|
<Field label="Azimuth LP">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={path ? fmtDeg(path.bearingLong) : ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Distance SP">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={path ? fmtKm(path.distanceShort) : ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Distance LP">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
className="font-mono bg-muted/40 cursor-default"
|
||||||
|
value={path ? fmtKm(path.distanceLong) : ''}
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Address" span={3}>
|
||||||
|
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="QSL message" span={3}>
|
||||||
|
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="QSL via" span={2}>
|
||||||
|
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open === 'awards' && (
|
||||||
|
<div className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" />
|
||||||
|
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open === 'my' && (
|
||||||
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
|
<Field label="Ant. azimuth (°)">
|
||||||
|
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Ant. elevation (°)">
|
||||||
|
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Ant. path">
|
||||||
|
<Input value={details.ant_path} placeholder="S / L / G" onChange={(e) => onChange({ ant_path: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Propagation">
|
||||||
|
<Select value={details.prop_mode || 'NONE'} onValueChange={(v) => onChange({ prop_mode: v === 'NONE' ? '' : v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === 'NONE' ? '—' : p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="TX power (W)">
|
||||||
|
<Input type="number" value={details.tx_pwr ?? ''} onChange={(e) => onChange({ tx_pwr: numOrUndef(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-end pb-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={satelliteMode} onCheckedChange={(c) => setSatellite(!!c)} />
|
||||||
|
Satellite mode
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Field label="Rig" span={3}>
|
||||||
|
<Input value={details.my_rig} placeholder="Flex 8600" onChange={(e) => onChange({ my_rig: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Antenna" span={3}>
|
||||||
|
<Input value={details.my_antenna} placeholder="UB640" onChange={(e) => onChange({ my_antenna: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
{satelliteMode && (
|
||||||
|
<>
|
||||||
|
<Field label="Satellite name" span={3}>
|
||||||
|
<Input value={details.sat_name} placeholder="AO-91" onChange={(e) => onChange({ sat_name: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Satellite mode" span={3}>
|
||||||
|
<Input value={details.sat_mode} placeholder="U/V" onChange={(e) => onChange({ sat_mode: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open === 'extended' && (
|
||||||
|
<div className="grid grid-cols-6 gap-2 px-3 py-2.5">
|
||||||
|
<Field label="Contest ID" span={2}>
|
||||||
|
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="SRX">
|
||||||
|
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="STX">
|
||||||
|
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Contacted email" span={3}>
|
||||||
|
<Input value={details.email} placeholder="op@example.com" onChange={(e) => onChange({ email: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type MenuItem =
|
||||||
|
| { type: 'item'; label: string; action: string; shortcut?: string; disabled?: boolean }
|
||||||
|
| { type: 'separator' };
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menus: Menu[];
|
||||||
|
onAction: (action: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Menubar({ menus, onAction }: Props) {
|
||||||
|
// Track which menu is open so hover-to-switch works like a desktop menubar.
|
||||||
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-stretch h-full">
|
||||||
|
{menus.map((menu) => (
|
||||||
|
<DropdownMenu
|
||||||
|
key={menu.name}
|
||||||
|
open={openMenu === menu.name}
|
||||||
|
onOpenChange={(o) => setOpenMenu(o ? menu.name : null)}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
onMouseEnter={() => {
|
||||||
|
// Only switch on hover if a menu is already open.
|
||||||
|
if (openMenu !== null && openMenu !== menu.name) setOpenMenu(menu.name);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'px-3 text-sm rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
openMenu === menu.name && 'bg-muted text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menu.label}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" sideOffset={4} className="min-w-[240px]">
|
||||||
|
{menu.items.map((item, i) =>
|
||||||
|
item.type === 'separator' ? (
|
||||||
|
<DropdownMenuSeparator key={i} />
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={i}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onSelect={() => onAction(item.action)}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { QSOForm } from '@/types';
|
||||||
|
|
||||||
|
type QSO = QSOForm;
|
||||||
|
|
||||||
|
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
|
||||||
|
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
|
||||||
|
const QSL_STATUSES = [
|
||||||
|
{ value: '_', label: '—' },
|
||||||
|
{ value: 'Y', label: 'Yes' },
|
||||||
|
{ value: 'N', label: 'No' },
|
||||||
|
{ value: 'R', label: 'Requested' },
|
||||||
|
{ value: 'I', label: 'Ignore' },
|
||||||
|
];
|
||||||
|
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
qso: QSO;
|
||||||
|
onSave: (q: QSO) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalISO(d: any): string {
|
||||||
|
if (!d) return '';
|
||||||
|
const date = new Date(d);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${date.getUTCFullYear()}-${p(date.getUTCMonth()+1)}-${p(date.getUTCDate())}T${p(date.getUTCHours())}:${p(date.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
function parseLocalISO(s: string): string | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
||||||
|
if (!m) return null;
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
|
||||||
|
}
|
||||||
|
function stringifyExtras(e?: Record<string, string>): string {
|
||||||
|
if (!e) return '';
|
||||||
|
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
|
||||||
|
}
|
||||||
|
function parseExtras(t: string): Record<string, string> | undefined {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const raw of t.split('\n')) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
if (idx < 0) continue;
|
||||||
|
const k = line.slice(0, idx).trim().toUpperCase();
|
||||||
|
const v = line.slice(idx + 1).trim();
|
||||||
|
if (k && v) out[k] = v;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length ? out : undefined;
|
||||||
|
}
|
||||||
|
function numOrUndef(v: any): number | undefined {
|
||||||
|
if (v === '' || v === null || v === undefined) return undefined;
|
||||||
|
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
||||||
|
return isNaN(n) ? undefined : n;
|
||||||
|
}
|
||||||
|
function intOrUndef(v: any): number | undefined {
|
||||||
|
const n = numOrUndef(v);
|
||||||
|
return n === undefined ? undefined : Math.trunc(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function F({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3 | 6; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-1 min-w-0', span === 2 && 'col-span-2', span === 3 && 'col-span-3', span === 6 && 'col-span-6')}>
|
||||||
|
<Label>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QslSelect({ value, onChange }: { value?: string; onChange: (v: string) => void }) {
|
||||||
|
return (
|
||||||
|
<Select value={value || '_'} onValueChange={(v) => onChange(v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{QSL_STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
|
||||||
|
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
|
||||||
|
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : '');
|
||||||
|
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : '');
|
||||||
|
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
|
||||||
|
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
|
||||||
|
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
|
||||||
|
const [localErr, setLocalErr] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
|
||||||
|
setDraft((d) => ({ ...d, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!draft.callsign?.trim()) { setLocalErr('Callsign required'); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setLocalErr('');
|
||||||
|
const out: any = {
|
||||||
|
...draft,
|
||||||
|
callsign: draft.callsign.trim().toUpperCase(),
|
||||||
|
grid: (draft.grid ?? '').trim().toUpperCase(),
|
||||||
|
gridsquare_ext: (draft.gridsquare_ext ?? '').trim().toUpperCase(),
|
||||||
|
station_callsign: (draft.station_callsign ?? '').trim().toUpperCase(),
|
||||||
|
operator: (draft.operator ?? '').trim().toUpperCase(),
|
||||||
|
my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
|
||||||
|
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
|
||||||
|
iota: (draft.iota ?? '').trim().toUpperCase(),
|
||||||
|
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
|
||||||
|
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
|
||||||
|
my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
|
||||||
|
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
|
||||||
|
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
|
||||||
|
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
|
||||||
|
qso_date_off: parseLocalISO(dateOff) ?? undefined,
|
||||||
|
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined,
|
||||||
|
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined,
|
||||||
|
dxcc: intOrUndef(draft.dxcc),
|
||||||
|
cqz: intOrUndef(draft.cqz),
|
||||||
|
ituz: intOrUndef(draft.ituz),
|
||||||
|
age: intOrUndef(draft.age),
|
||||||
|
srx: intOrUndef(draft.srx),
|
||||||
|
stx: intOrUndef(draft.stx),
|
||||||
|
my_dxcc: intOrUndef(draft.my_dxcc),
|
||||||
|
my_cq_zone: intOrUndef(draft.my_cq_zone),
|
||||||
|
my_itu_zone: intOrUndef(draft.my_itu_zone),
|
||||||
|
lat: numOrUndef(draft.lat), lon: numOrUndef(draft.lon),
|
||||||
|
my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
|
||||||
|
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
|
||||||
|
tx_pwr: numOrUndef(draft.tx_pwr),
|
||||||
|
extras: parseExtras(extrasText),
|
||||||
|
};
|
||||||
|
onSave(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) save();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
});
|
||||||
|
|
||||||
|
const extrasCount = useMemo(
|
||||||
|
() => (draft.extras ? Object.keys(draft.extras).length : 0),
|
||||||
|
[draft.extras],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
|
<DialogTitle>Edit QSO</DialogTitle>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
|
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0">
|
||||||
|
<TabsList className="px-3 overflow-x-auto">
|
||||||
|
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||||
|
<TabsTrigger value="contacted">Contacted</TabsTrigger>
|
||||||
|
<TabsTrigger value="qsl">QSL</TabsTrigger>
|
||||||
|
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||||
|
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||||
|
<TabsTrigger value="mystation">My station</TabsTrigger>
|
||||||
|
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||||
|
<TabsTrigger value="extras">
|
||||||
|
Extras
|
||||||
|
{extrasCount > 0 && (
|
||||||
|
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{localErr && (
|
||||||
|
<div className="mx-5 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2">
|
||||||
|
{localErr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-y-auto px-5 py-4 flex-1">
|
||||||
|
<TabsContent value="basic" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Callsign" span={6}>
|
||||||
|
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11"
|
||||||
|
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
|
||||||
|
</F>
|
||||||
|
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F>
|
||||||
|
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F>
|
||||||
|
<F label="Band">
|
||||||
|
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</F>
|
||||||
|
<F label="Mode">
|
||||||
|
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</F>
|
||||||
|
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F>
|
||||||
|
<F label="Band RX">
|
||||||
|
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_">—</SelectItem>
|
||||||
|
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</F>
|
||||||
|
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F>
|
||||||
|
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F>
|
||||||
|
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F>
|
||||||
|
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F>
|
||||||
|
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="contacted" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F>
|
||||||
|
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F>
|
||||||
|
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F>
|
||||||
|
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F>
|
||||||
|
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F>
|
||||||
|
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F>
|
||||||
|
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F>
|
||||||
|
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F>
|
||||||
|
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F>
|
||||||
|
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F>
|
||||||
|
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F>
|
||||||
|
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F>
|
||||||
|
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F>
|
||||||
|
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F>
|
||||||
|
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F>
|
||||||
|
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F>
|
||||||
|
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="qsl" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F>
|
||||||
|
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F>
|
||||||
|
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F>
|
||||||
|
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F>
|
||||||
|
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F>
|
||||||
|
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F>
|
||||||
|
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F>
|
||||||
|
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F>
|
||||||
|
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F>
|
||||||
|
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F>
|
||||||
|
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F>
|
||||||
|
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F>
|
||||||
|
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F>
|
||||||
|
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F>
|
||||||
|
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F>
|
||||||
|
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F>
|
||||||
|
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F>
|
||||||
|
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F>
|
||||||
|
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="contest" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
|
||||||
|
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
|
||||||
|
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
|
||||||
|
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
|
||||||
|
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
|
||||||
|
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sat" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Propagation mode">
|
||||||
|
<Select value={draft.prop_mode || '_'} onValueChange={(v) => set('prop_mode', v === '_' ? '' : v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{PROP_MODES.map((p) => <SelectItem key={p} value={p}>{p === '_' ? '—' : p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</F>
|
||||||
|
<F label="Satellite name"><Input value={draft.sat_name ?? ''} placeholder="AO-91" onChange={(e) => set('sat_name', e.target.value)} /></F>
|
||||||
|
<F label="Satellite mode"><Input value={draft.sat_mode ?? ''} placeholder="U/V" onChange={(e) => set('sat_mode', e.target.value)} /></F>
|
||||||
|
<F label="Antenna AZ (°)"><Input type="number" value={draft.ant_az ?? ''} onChange={(e) => set('ant_az', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Antenna EL (°)"><Input type="number" value={draft.ant_el ?? ''} onChange={(e) => set('ant_el', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Antenna path"><Input value={draft.ant_path ?? ''} placeholder="S, L, G" onChange={(e) => set('ant_path', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="mystation" className="mt-0 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">These override the active station profile for this QSO only.</p>
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Station callsign" span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||||
|
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||||
|
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||||
|
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||||
|
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', e.target.value)} /></F>
|
||||||
|
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||||
|
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||||
|
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="CQ zone"><Input type="number" value={draft.my_cq_zone ?? ''} onChange={(e) => set('my_cq_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="ITU zone"><Input type="number" value={draft.my_itu_zone ?? ''} onChange={(e) => set('my_itu_zone', intOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="IOTA"><Input value={draft.my_iota ?? ''} onChange={(e) => set('my_iota', e.target.value)} /></F>
|
||||||
|
<F label="SOTA ref"><Input value={draft.my_sota_ref ?? ''} onChange={(e) => set('my_sota_ref', e.target.value)} /></F>
|
||||||
|
<F label="POTA ref"><Input value={draft.my_pota_ref ?? ''} onChange={(e) => set('my_pota_ref', e.target.value)} /></F>
|
||||||
|
<F label="Lat"><Input type="number" step="0.000001" value={draft.my_lat ?? ''} onChange={(e) => set('my_lat', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Lon"><Input type="number" step="0.000001" value={draft.my_lon ?? ''} onChange={(e) => set('my_lon', numOrUndef(e.target.value) as any)} /></F>
|
||||||
|
<F label="Street" span={2}><Input value={draft.my_street ?? ''} onChange={(e) => set('my_street', e.target.value)} /></F>
|
||||||
|
<F label="City" span={2}><Input value={draft.my_city ?? ''} onChange={(e) => set('my_city', e.target.value)} /></F>
|
||||||
|
<F label="Postal" span={2}><Input value={draft.my_postal_code ?? ''} onChange={(e) => set('my_postal_code', e.target.value)} /></F>
|
||||||
|
<F label="Rig" span={3}><Input value={draft.my_rig ?? ''} onChange={(e) => set('my_rig', e.target.value)} /></F>
|
||||||
|
<F label="Antenna" span={3}><Input value={draft.my_antenna ?? ''} onChange={(e) => set('my_antenna', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notes" className="mt-0">
|
||||||
|
<div className="grid grid-cols-6 gap-3">
|
||||||
|
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F>
|
||||||
|
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="extras" className="mt-0 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
ADIF fields not promoted to first-class columns. One per line:{' '}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
|
||||||
|
</p>
|
||||||
|
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="!flex-row gap-2">
|
||||||
|
<Button variant="outline" className="text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => onDelete(draft.id)} disabled={saving}>
|
||||||
|
<Trash2 className="size-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowDown, ArrowUp, Copy, Plus, Star, StarOff, Trash2,
|
||||||
|
ChevronDown, ChevronRight,
|
||||||
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
|
Compass, Wifi, Construction,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
|
GetListsSettings, SaveListsSettings,
|
||||||
|
GetCATSettings, SaveCATSettings,
|
||||||
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
|
import type { main as mainModels } from '../../wailsjs/go/models';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type LookupSettings = LookupSettingsForm;
|
||||||
|
type StationSettings = StationSettingsForm;
|
||||||
|
type ListsSettings = ListsSettingsForm;
|
||||||
|
type ModePreset = ModePresetForm;
|
||||||
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
|
|
||||||
|
const emptyProfile = (): Profile => ({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
callsign: '', operator: '',
|
||||||
|
my_grid: '', my_country: '',
|
||||||
|
my_state: '', my_cnty: '',
|
||||||
|
my_street: '', my_city: '', my_postal_code: '',
|
||||||
|
my_sota_ref: '', my_pota_ref: '',
|
||||||
|
my_rig: '', my_antenna: '',
|
||||||
|
tx_pwr: undefined,
|
||||||
|
is_active: false,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: '' as any,
|
||||||
|
updated_at: '' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialSection?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ====== Tree definition ======
|
||||||
|
Section IDs are stable strings — adding new ones means adding a panel below.
|
||||||
|
`disabled: true` greys them out and shows the "coming soon" placeholder. */
|
||||||
|
type SectionId =
|
||||||
|
| 'station'
|
||||||
|
| 'profiles'
|
||||||
|
| 'lookup'
|
||||||
|
| 'lists-bands'
|
||||||
|
| 'lists-modes'
|
||||||
|
| 'cluster'
|
||||||
|
| 'backup'
|
||||||
|
| 'awards'
|
||||||
|
| 'cat'
|
||||||
|
| 'rotator'
|
||||||
|
| 'antenna'
|
||||||
|
| 'audio';
|
||||||
|
|
||||||
|
type TreeNode =
|
||||||
|
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
||||||
|
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
||||||
|
|
||||||
|
const TREE: TreeNode[] = [
|
||||||
|
{
|
||||||
|
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||||
|
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||||
|
{ kind: 'item', label: 'Profiles (portable, home, contest)', id: 'profiles' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
|
||||||
|
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
|
||||||
|
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
|
||||||
|
{ kind: 'item', label: 'Bands', id: 'lists-bands' },
|
||||||
|
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
|
||||||
|
]},
|
||||||
|
{ kind: 'item', label: 'DX Cluster', id: 'cluster', disabled: true },
|
||||||
|
{ kind: 'item', label: 'Backup / Export', id: 'backup', disabled: true },
|
||||||
|
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'group', label: 'Hardware Configuration', icon: Server, children: [
|
||||||
|
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
|
||||||
|
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator', disabled: true },
|
||||||
|
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
|
||||||
|
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
|
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||||
|
station: 'Station Information',
|
||||||
|
profiles: 'Profiles',
|
||||||
|
lookup: 'Callsign Lookup',
|
||||||
|
'lists-bands': 'Bands',
|
||||||
|
'lists-modes': 'Modes & default RST',
|
||||||
|
cluster: 'DX Cluster',
|
||||||
|
backup: 'Backup / Export',
|
||||||
|
awards: 'Awards',
|
||||||
|
cat: 'CAT interface',
|
||||||
|
rotator: 'Rotator',
|
||||||
|
antenna: 'Antenna',
|
||||||
|
audio: 'Audio devices',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Tree component =====
|
||||||
|
|
||||||
|
interface TreeProps {
|
||||||
|
selected: SectionId;
|
||||||
|
onSelect: (id: SectionId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tree({ selected, onSelect }: TreeProps) {
|
||||||
|
return (
|
||||||
|
<nav className="text-sm">
|
||||||
|
{TREE.map((node, i) => (
|
||||||
|
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNodeView({
|
||||||
|
node, depth, selected, onSelect,
|
||||||
|
}: { node: TreeNode; depth: number; selected: SectionId; onSelect: (id: SectionId) => void }) {
|
||||||
|
if (node.kind === 'item') {
|
||||||
|
const isActive = selected === node.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => { if (!node.disabled) onSelect(node.id); }}
|
||||||
|
disabled={node.disabled}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2 py-1.5 rounded-md text-[12.5px] transition-colors flex items-center',
|
||||||
|
isActive ? 'bg-accent text-accent-foreground font-semibold' : 'hover:bg-muted/60',
|
||||||
|
node.disabled && 'opacity-50 cursor-not-allowed italic',
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: 8 + depth * 14 }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{node.label}</span>
|
||||||
|
{node.disabled && (
|
||||||
|
<Construction className="ml-auto size-3 shrink-0 opacity-60" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// group
|
||||||
|
const [open, setOpen] = useState(node.defaultOpen ?? false);
|
||||||
|
const Icon = node.icon;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-md text-[12px] uppercase tracking-wider text-muted-foreground hover:text-foreground flex items-center gap-1.5 font-semibold"
|
||||||
|
style={{ paddingLeft: 8 + depth * 14 }}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||||
|
{Icon && <Icon className="size-3.5 opacity-70" />}
|
||||||
|
<span>{node.label}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((c, i) => (
|
||||||
|
<TreeNodeView key={i} node={c} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Section content panels =====
|
||||||
|
|
||||||
|
function SectionHeader({ title, hint }: { title: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<header className="mb-4">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||||
|
const label = SECTION_LABELS[id] ?? id;
|
||||||
|
const IconCmp = Icon ?? Construction;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center text-muted-foreground gap-2 py-12">
|
||||||
|
<IconCmp className="size-10 opacity-40" />
|
||||||
|
<div className="text-base font-semibold text-foreground/70">{label}</div>
|
||||||
|
<div className="text-sm">Module coming soon.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||||
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [clearing, setClearing] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const [lookup, setLookup] = useState<LookupSettings>({
|
||||||
|
qrz_user: '', qrz_password: '',
|
||||||
|
hamqth_user: '', hamqth_password: '',
|
||||||
|
primary: '', failsafe: '',
|
||||||
|
cache_ttl_days: 30,
|
||||||
|
});
|
||||||
|
// Per-provider Test state — keeps the success/error feedback adjacent
|
||||||
|
// to the button. Cleared on the next test run for that provider.
|
||||||
|
type TestResult = { ok: boolean; msg: string };
|
||||||
|
const [lookupTest, setLookupTest] = useState<Record<string, TestResult | undefined>>({});
|
||||||
|
const [lookupTesting, setLookupTesting] = useState<Record<string, boolean>>({});
|
||||||
|
// The Station Information panel now edits the full active profile
|
||||||
|
// (not a flat 6-field StationSettings). Profile selection happens in
|
||||||
|
// the Profiles panel; any edit here saves back to whichever profile
|
||||||
|
// is currently active.
|
||||||
|
const [activeProfile, setActiveProfile] = useState<Profile | null>(null);
|
||||||
|
const updateActive = (patch: Partial<Profile>) =>
|
||||||
|
setActiveProfile((p) => (p ? { ...p, ...patch } : p));
|
||||||
|
const [lists, setLists] = useState<ListsSettings>({ bands: [], modes: [] });
|
||||||
|
const [bandsText, setBandsText] = useState('');
|
||||||
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||||||
|
digital_default: 'FT8',
|
||||||
|
});
|
||||||
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
|
// State for ProfilesPanel — lifted here because PANELS[selected]() calls
|
||||||
|
// the panel as a plain function, not as a JSX element, so any useState
|
||||||
|
// inside the panel function would violate the Rules of Hooks.
|
||||||
|
const [profileSelectedId, setProfileSelectedId] = useState<number>(0);
|
||||||
|
const [profileNameDraft, setProfileNameDraft] = useState<string>('');
|
||||||
|
|
||||||
|
async function reloadProfiles() {
|
||||||
|
try {
|
||||||
|
const list = await ListProfiles();
|
||||||
|
setProfiles(list);
|
||||||
|
// Refresh the active-profile editor in case activation changed.
|
||||||
|
const ap = await GetActiveProfile();
|
||||||
|
setActiveProfile(ap as Profile);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the ProfilesPanel selector in sync with the loaded list. If the
|
||||||
|
// currently-selected profile is gone (post-delete) or none is selected
|
||||||
|
// yet, default to the active one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profiles.length) return;
|
||||||
|
const stillThere = profiles.some((p) => (p.id as number) === profileSelectedId);
|
||||||
|
if (!stillThere) {
|
||||||
|
const next = profiles.find((p) => p.is_active) ?? profiles[0];
|
||||||
|
setProfileSelectedId(next.id as number);
|
||||||
|
setProfileNameDraft(next.name);
|
||||||
|
}
|
||||||
|
}, [profiles, profileSelectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [l, ls, c, ap] = await Promise.all([
|
||||||
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
|
]);
|
||||||
|
setLookup(l);
|
||||||
|
setActiveProfile(ap as Profile);
|
||||||
|
setLists(ls);
|
||||||
|
await reloadProfiles();
|
||||||
|
setBandsText((ls.bands ?? []).join('\n'));
|
||||||
|
setCatCfg(c);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function addMode() {
|
||||||
|
setLists((l) => ({
|
||||||
|
...l,
|
||||||
|
modes: [...(l.modes ?? []), { name: '', default_rst_sent: '59', default_rst_rcvd: '59' }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function removeMode(i: number) {
|
||||||
|
setLists((l) => {
|
||||||
|
const next = [...(l.modes ?? [])];
|
||||||
|
next.splice(i, 1);
|
||||||
|
return { ...l, modes: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function moveMode(i: number, dir: -1 | 1) {
|
||||||
|
setLists((l) => {
|
||||||
|
const next = [...(l.modes ?? [])];
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= next.length) return l;
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
return { ...l, modes: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateMode(i: number, patch: Partial<ModePreset>) {
|
||||||
|
setLists((l) => {
|
||||||
|
const next = [...(l.modes ?? [])];
|
||||||
|
next[i] = { ...next[i], ...patch } as ModePreset;
|
||||||
|
return { ...l, modes: next };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true); setErr(''); setMsg('');
|
||||||
|
try {
|
||||||
|
// Bands: dedup, lowercase, trim.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const bands: string[] = [];
|
||||||
|
for (const line of bandsText.split('\n')) {
|
||||||
|
const b = line.trim().toLowerCase();
|
||||||
|
if (b && !seen.has(b)) { seen.add(b); bands.push(b); }
|
||||||
|
}
|
||||||
|
const modes = (lists.modes ?? [])
|
||||||
|
.map((m) => ({
|
||||||
|
name: (m.name ?? '').trim().toUpperCase(),
|
||||||
|
default_rst_sent: (m.default_rst_sent ?? '').trim(),
|
||||||
|
default_rst_rcvd: (m.default_rst_rcvd ?? '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((m) => m.name !== '');
|
||||||
|
await SaveListsSettings({ bands, modes } as any);
|
||||||
|
|
||||||
|
if (activeProfile) {
|
||||||
|
await SaveProfile({
|
||||||
|
...activeProfile,
|
||||||
|
callsign: (activeProfile.callsign ?? '').trim().toUpperCase(),
|
||||||
|
operator: (activeProfile.operator ?? '').trim().toUpperCase(),
|
||||||
|
my_grid: (activeProfile.my_grid ?? '').trim().toUpperCase(),
|
||||||
|
my_sota_ref: (activeProfile.my_sota_ref ?? '').trim().toUpperCase(),
|
||||||
|
my_pota_ref: (activeProfile.my_pota_ref ?? '').trim().toUpperCase(),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
await SaveLookupSettings(lookup as any);
|
||||||
|
await SaveCATSettings(catCfg as any);
|
||||||
|
|
||||||
|
setMsg('Settings saved.');
|
||||||
|
onSaved();
|
||||||
|
setTimeout(onClose, 500);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache() {
|
||||||
|
setClearing(true); setErr(''); setMsg('');
|
||||||
|
try {
|
||||||
|
await ClearLookupCache();
|
||||||
|
setMsg('Cache cleared.');
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setClearing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProvider(provider: 'qrz' | 'hamqth') {
|
||||||
|
setLookupTesting((s) => ({ ...s, [provider]: true }));
|
||||||
|
setLookupTest((s) => ({ ...s, [provider]: undefined }));
|
||||||
|
const user = provider === 'qrz' ? lookup.qrz_user : lookup.hamqth_user;
|
||||||
|
const pwd = provider === 'qrz' ? lookup.qrz_password : lookup.hamqth_password;
|
||||||
|
try {
|
||||||
|
const r = await TestLookupProvider(provider, '', user ?? '', pwd ?? '');
|
||||||
|
setLookupTest((s) => ({ ...s, [provider]: { ok: true, msg: `OK — ${r.callsign} (${r.name || r.country || 'no name'})` } }));
|
||||||
|
} catch (e: any) {
|
||||||
|
setLookupTest((s) => ({ ...s, [provider]: { ok: false, msg: String(e?.message ?? e) } }));
|
||||||
|
} finally {
|
||||||
|
setLookupTesting((s) => ({ ...s, [provider]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumb = useMemo(() => SECTION_LABELS[selected] ?? selected, [selected]);
|
||||||
|
|
||||||
|
// === Section content renderers ===
|
||||||
|
|
||||||
|
function StationPanel() {
|
||||||
|
if (!activeProfile) {
|
||||||
|
return <div className="text-muted-foreground text-sm">Loading profile…</div>;
|
||||||
|
}
|
||||||
|
const p = activeProfile;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Station Information"
|
||||||
|
hint={`Editing the active profile: ${p.name}. Switch profiles in the Profiles section to edit a different one.`}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-w-2xl">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Station callsign</Label>
|
||||||
|
<Input className="font-mono uppercase" value={p.callsign ?? ''} onChange={(e) => updateActive({ callsign: e.target.value })} placeholder="F4XYZ" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Operator</Label>
|
||||||
|
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>My grid</Label>
|
||||||
|
<Input className="font-mono uppercase" value={p.my_grid ?? ''} onChange={(e) => updateActive({ my_grid: e.target.value })} placeholder="JN18BU" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>My country</Label>
|
||||||
|
<Input value={p.my_country ?? ''} onChange={(e) => updateActive({ my_country: e.target.value })} placeholder="France" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>State / pref</Label>
|
||||||
|
<Input value={p.my_state ?? ''} onChange={(e) => updateActive({ my_state: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>County</Label>
|
||||||
|
<Input value={p.my_cnty ?? ''} onChange={(e) => updateActive({ my_cnty: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Street address</Label>
|
||||||
|
<Input value={p.my_street ?? ''} onChange={(e) => updateActive({ my_street: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Postal code</Label>
|
||||||
|
<Input value={p.my_postal_code ?? ''} onChange={(e) => updateActive({ my_postal_code: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>City</Label>
|
||||||
|
<Input value={p.my_city ?? ''} onChange={(e) => updateActive({ my_city: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>SOTA ref</Label>
|
||||||
|
<Input className="font-mono uppercase" value={p.my_sota_ref ?? ''} onChange={(e) => updateActive({ my_sota_ref: e.target.value })} placeholder="F/AB-001" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>POTA ref</Label>
|
||||||
|
<Input className="font-mono uppercase" value={p.my_pota_ref ?? ''} onChange={(e) => updateActive({ my_pota_ref: e.target.value })} placeholder="FF-1234" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Rig</Label>
|
||||||
|
<Input value={p.my_rig ?? ''} onChange={(e) => updateActive({ my_rig: e.target.value })} placeholder="Flex 8600" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Antenna</Label>
|
||||||
|
<Input value={p.my_antenna ?? ''} onChange={(e) => updateActive({ my_antenna: e.target.value })} placeholder="Ultrabeam UB40" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TX power (W)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={p.tx_pwr ?? ''}
|
||||||
|
onChange={(e) => updateActive({ tx_pwr: e.target.value === '' ? undefined : (parseFloat(e.target.value) || undefined) })}
|
||||||
|
placeholder="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile actions — kept at the SettingsModal level so the ProfilesPanel
|
||||||
|
// renderer can stay hooks-free (the PANELS map calls it as a plain
|
||||||
|
// function, not as a JSX component).
|
||||||
|
const activeProfileObj = profiles.find((p) => p.is_active) ?? profiles[0];
|
||||||
|
const currentProfile = profiles.find((p) => (p.id as number) === profileSelectedId);
|
||||||
|
|
||||||
|
async function profileActivate() {
|
||||||
|
if (!currentProfile) return;
|
||||||
|
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function profileRemove() {
|
||||||
|
if (!currentProfile) return;
|
||||||
|
if (!confirm(`Delete profile "${currentProfile.name}"? All its settings will be lost.`)) return;
|
||||||
|
try { await DeleteProfile(currentProfile.id as number); await reloadProfiles(); }
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function profileDuplicate() {
|
||||||
|
if (!currentProfile) return;
|
||||||
|
const name = prompt(`Name for the new profile (copy of "${currentProfile.name}"):`, `${currentProfile.name} Copy`);
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const dup = await DuplicateProfile(currentProfile.id as number, name.trim());
|
||||||
|
await reloadProfiles();
|
||||||
|
setProfileSelectedId(dup.id as number);
|
||||||
|
setProfileNameDraft(dup.name);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function profileCreateBlank() {
|
||||||
|
const name = prompt('Name for the new profile:', 'New profile');
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
try {
|
||||||
|
const blank = emptyProfile();
|
||||||
|
blank.name = name.trim();
|
||||||
|
const saved = await SaveProfile(blank as any);
|
||||||
|
await reloadProfiles();
|
||||||
|
setProfileSelectedId(saved.id as number);
|
||||||
|
setProfileNameDraft(saved.name);
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function profileRenameCurrent() {
|
||||||
|
if (!currentProfile || profileNameDraft.trim() === currentProfile.name) return;
|
||||||
|
try {
|
||||||
|
await SaveProfile({ ...currentProfile, name: profileNameDraft.trim() } as any);
|
||||||
|
await reloadProfiles();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfilesPanel() {
|
||||||
|
const current = currentProfile;
|
||||||
|
const active = activeProfileObj;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Profiles"
|
||||||
|
hint="Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[140px_1fr] items-center gap-x-3 gap-y-3">
|
||||||
|
<Label>Configuration ID</Label>
|
||||||
|
<Select
|
||||||
|
value={String(profileSelectedId)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const id = parseInt(v, 10);
|
||||||
|
setProfileSelectedId(id);
|
||||||
|
setProfileNameDraft(profiles.find((p) => (p.id as number) === id)?.name ?? '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<SelectItem key={p.id as number} value={String(p.id)}>
|
||||||
|
{p.name}{p.callsign ? ` — ${p.callsign}` : ''}{p.is_active ? ' (active)' : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label>Description</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={profileNameDraft}
|
||||||
|
onChange={(e) => setProfileNameDraft(e.target.value)}
|
||||||
|
onBlur={profileRenameCurrent}
|
||||||
|
placeholder="Profile name"
|
||||||
|
disabled={!current}
|
||||||
|
/>
|
||||||
|
{current?.is_active && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold tracking-wider bg-emerald-100 text-emerald-800 border border-emerald-300">
|
||||||
|
ACTIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||||||
|
<Button variant="outline" size="sm" onClick={profileCreateBlank} title="Create a new empty profile">
|
||||||
|
<Plus className="size-3.5 mr-1" /> New
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={profileDuplicate} disabled={!current} title="Clone the selected profile (keeps all its fields)">
|
||||||
|
<Copy className="size-3.5 mr-1" /> Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm"
|
||||||
|
onClick={profileActivate}
|
||||||
|
disabled={!current || current.is_active}
|
||||||
|
title="Activate the selected profile — new QSOs will use its MY_* fields"
|
||||||
|
>
|
||||||
|
<Star className="size-3.5 mr-1" /> Set active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm"
|
||||||
|
onClick={profileRemove}
|
||||||
|
disabled={!current || profiles.length <= 1}
|
||||||
|
className="text-destructive hover:text-destructive ml-auto"
|
||||||
|
title={profiles.length <= 1 ? 'Cannot delete the last profile' : 'Delete the selected profile'}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 mr-1" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{current && !current.is_active && (
|
||||||
|
<div className="text-xs text-muted-foreground bg-muted/30 border border-border rounded-md p-2.5">
|
||||||
|
You're viewing <strong>{current.name}</strong>. The active profile is <strong>{active?.name}</strong> — its values are stamped on new QSOs. Click <em>Set active</em> to switch.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LookupPanel() {
|
||||||
|
// Per-row provider editor — kept inline because it's only used twice
|
||||||
|
// and needs closure access to the parent state.
|
||||||
|
const row = (
|
||||||
|
key: 'qrz' | 'hamqth', label: string, userField: 'qrz_user' | 'hamqth_user',
|
||||||
|
pwdField: 'qrz_password' | 'hamqth_password',
|
||||||
|
) => {
|
||||||
|
const test = lookupTest[key];
|
||||||
|
const testing = lookupTesting[key];
|
||||||
|
const hasCreds = !!(lookup[userField] && lookup[pwdField]);
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-border align-middle">
|
||||||
|
<td className="px-3 py-2 font-semibold whitespace-nowrap">{label}</td>
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="lookup-primary"
|
||||||
|
checked={lookup.primary === key}
|
||||||
|
onChange={() => setLookup((s) => ({ ...s, primary: key, failsafe: s.failsafe === key ? '' : s.failsafe }))}
|
||||||
|
disabled={!hasCreds}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="lookup-failsafe"
|
||||||
|
checked={lookup.failsafe === key}
|
||||||
|
onChange={() => setLookup((s) => ({ ...s, failsafe: key, primary: s.primary === key ? '' : s.primary }))}
|
||||||
|
disabled={!hasCreds || lookup.primary === key}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
value={lookup[userField] ?? ''}
|
||||||
|
onChange={(e) => setLookup((s) => ({ ...s, [userField]: e.target.value }))}
|
||||||
|
placeholder="User"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
type="password"
|
||||||
|
value={lookup[pwdField] ?? ''}
|
||||||
|
onChange={(e) => setLookup((s) => ({ ...s, [pwdField]: e.target.value }))}
|
||||||
|
placeholder="Password"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 whitespace-nowrap">
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm"
|
||||||
|
onClick={() => testProvider(key)}
|
||||||
|
disabled={!hasCreds || testing}
|
||||||
|
title="Run a sample lookup against the active profile's callsign to verify credentials"
|
||||||
|
>
|
||||||
|
{testing ? 'Testing…' : 'Test'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-2 py-2 text-xs', test?.ok ? 'text-emerald-700' : 'text-destructive')}>
|
||||||
|
{test?.msg}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Callsign Lookup"
|
||||||
|
hint="Pick a Primary provider and an optional Failsafe (queried only when Primary returns no data). Click Test to verify credentials without saving."
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-2">Provider</th>
|
||||||
|
<th className="px-2 py-2 w-20">Primary</th>
|
||||||
|
<th className="px-2 py-2 w-20">Failsafe</th>
|
||||||
|
<th className="text-left px-2 py-2">User</th>
|
||||||
|
<th className="text-left px-2 py-2">Password</th>
|
||||||
|
<th className="px-2 py-2 w-20"></th>
|
||||||
|
<th className="text-left px-2 py-2">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{row('qrz', 'QRZ.com', 'qrz_user', 'qrz_password')}
|
||||||
|
{row('hamqth', 'HamQTH', 'hamqth_user', 'hamqth_password')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-border">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Cache</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Successful lookups are cached locally so the same callsign isn't fetched twice. TTL controls how long before a fresh query is made.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="space-y-1 w-40">
|
||||||
|
<Label>TTL (days)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={3650}
|
||||||
|
value={lookup.cache_ttl_days}
|
||||||
|
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={clearCache} disabled={clearing}>
|
||||||
|
{clearing ? 'Clearing…' : 'Clear cache now'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BandsPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Bands" hint="One ADIF band per line (e.g. 20m, 2m, 70cm). Order = display order in the entry form and the band-slot grid." />
|
||||||
|
<Textarea
|
||||||
|
className="font-mono min-h-[260px] max-w-md"
|
||||||
|
value={bandsText}
|
||||||
|
onChange={(e) => setBandsText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModesPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Modes & default RST"
|
||||||
|
hint="When you pick a mode in the entry form, RST sent/rcvd auto-fill with these defaults (unless you've typed something)."
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border border-border overflow-hidden max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-2 bg-muted text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||||
|
<span className="w-12">Order</span>
|
||||||
|
<span>Mode (ADIF)</span>
|
||||||
|
<span>RST sent</span>
|
||||||
|
<span>RST rcvd</span>
|
||||||
|
<span className="w-8"></span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{(lists.modes ?? []).map((m, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_auto] gap-2 px-3 py-1.5 items-center">
|
||||||
|
<div className="flex gap-0.5 w-12">
|
||||||
|
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, -1)} disabled={i === 0}>
|
||||||
|
<ArrowUp className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" className="size-6" onClick={() => moveMode(i, 1)} disabled={i === (lists.modes?.length ?? 0) - 1}>
|
||||||
|
<ArrowDown className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input className="font-mono uppercase h-7" value={m.name} onChange={(e) => updateMode(i, { name: e.target.value })} placeholder="SSB" />
|
||||||
|
<Input className="font-mono h-7" value={m.default_rst_sent ?? ''} onChange={(e) => updateMode(i, { default_rst_sent: e.target.value })} placeholder="59" />
|
||||||
|
<Input className="font-mono h-7" value={m.default_rst_rcvd ?? ''} onChange={(e) => updateMode(i, { default_rst_rcvd: e.target.value })} placeholder="59" />
|
||||||
|
<Button size="icon" variant="ghost" className="size-6 text-destructive hover:bg-destructive/10" onClick={() => removeMode(i)}>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={addMode} className="mt-3">
|
||||||
|
<Plus className="size-3.5" /> Add mode
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CATPanel() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="CAT interface (OmniRig)"
|
||||||
|
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — HamLog just talks to it."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable CAT
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Backend</Label>
|
||||||
|
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
|
||||||
|
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>OmniRig rig slot</Label>
|
||||||
|
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">Rig 1</SelectItem>
|
||||||
|
<SelectItem value="2">Rig 2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Poll interval (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={50} max={2000} step={50}
|
||||||
|
value={catCfg.poll_ms}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, poll_ms: parseInt(e.target.value) || 250 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>CAT delay (ms)</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={0} max={500} step={10}
|
||||||
|
value={catCfg.delay_ms}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Default digital mode (when rig reports DIG)</Label>
|
||||||
|
<Select
|
||||||
|
value={catCfg.digital_default || 'FT8'}
|
||||||
|
onValueChange={(v) => setCatCfg((s) => ({ ...s, digital_default: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['FT8','FT4','RTTY','PSK31','MFSK','JS8','JT65','JT9','OLIVIA','DIGITALVOICE','DATA'].map(m => (
|
||||||
|
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure your rig (COM port, baud rate, model) in OmniRig's own settings GUI first.
|
||||||
|
HamLog will read whichever Rig slot you select here. Set <strong>CAT delay</strong>
|
||||||
|
{' '}above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu).
|
||||||
|
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||||
|
{' '}is the specific mode HamLog will surface (and log).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map sections to their content + icon (for placeholder).
|
||||||
|
const PANELS: Record<SectionId, () => JSX.Element> = {
|
||||||
|
station: StationPanel,
|
||||||
|
profiles: ProfilesPanel,
|
||||||
|
lookup: LookupPanel,
|
||||||
|
'lists-bands': BandsPanel,
|
||||||
|
'lists-modes': ModesPanel,
|
||||||
|
cluster: () => <ComingSoon id="cluster" icon={Wifi} />,
|
||||||
|
backup: () => <ComingSoon id="backup" icon={Database} />,
|
||||||
|
awards: () => <ComingSoon id="awards" icon={Award} />,
|
||||||
|
cat: CATPanel,
|
||||||
|
rotator: () => <ComingSoon id="rotator" icon={Compass} />,
|
||||||
|
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
|
||||||
|
audio: () => <ComingSoon id="audio" icon={Server} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[960px] w-full max-h-[90vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Preferences</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">Configure HamLog modules — station, lookup, hardware…</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-muted-foreground">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[260px_1fr] min-h-0 overflow-hidden">
|
||||||
|
{/* Left sidebar tree */}
|
||||||
|
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||||||
|
<Tree selected={selected} onSelect={setSelected} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right content pane */}
|
||||||
|
<div className="overflow-y-auto p-6">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||||
|
{breadcrumb}
|
||||||
|
</div>
|
||||||
|
{PANELS[selected]?.()}
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="mt-6 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-md px-3 py-2 max-w-2xl">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && (
|
||||||
|
<div className="mt-6 text-xs rounded-md px-3 py-2 max-w-2xl text-[color:var(--color-ok)] bg-[color:var(--color-ok)]/10 border border-[color:var(--color-ok)]/30">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col gap-2 text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title ref={ref} className={cn('text-base font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel ref={ref} className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground shadow',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
accent: 'border-transparent bg-accent text-accent-foreground',
|
||||||
|
success: 'border-transparent bg-[color:var(--color-ok)]/15 text-[color:var(--color-ok)]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
xs: 'h-7 rounded-md px-2.5 text-xs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-sm border border-input shadow ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-stone-900/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideClose?: boolean }
|
||||||
|
>(({ className, children, hideClose, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-0 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{!hideClose && (
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col gap-1 px-5 py-4 border-b border-border', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 px-5 py-3 border-t border-border bg-muted/30 rounded-b-lg', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title ref={ref} className={cn('text-base font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center justify-between gap-3 rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset && 'pl-8', className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||||
|
<span className={cn('ml-auto text-xs tracking-widest opacity-60 font-mono', className)} {...props} />
|
||||||
|
);
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-xs font-medium text-muted-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// Minimal scroll-area wrapper — Radix has a fancier version, but native
|
||||||
|
// overflow with our styled scrollbar is good enough for the logbook table.
|
||||||
|
export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ScrollArea.displayName = 'ScrollArea';
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-full items-center justify-between rounded-md border border-input bg-background px-2.5 py-1 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton ref={ref} className={cn('flex cursor-default items-center justify-center py-1', className)} {...props}>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-[20rem] min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
position === 'popper' && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport className={cn('p-1', position === 'popper' && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]')}>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-xs font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-7 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('inline-flex h-9 items-center justify-start gap-1 border-b border-border w-full px-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap px-3 py-1.5 text-xs font-medium text-muted-foreground border-b-2 border-transparent ring-offset-background transition-all hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary data-[state=active]:text-primary data-[state=active]:font-semibold -mb-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 overflow-hidden rounded-md bg-stone-900 px-2.5 py-1.5 text-xs text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Maidenhead grid locator ⇄ lat/lon, plus great-circle distance + bearing.
|
||||||
|
//
|
||||||
|
// Used to drive the "AZ SP/LP / Dist SP/LP" readouts in the entry form so
|
||||||
|
// the operator knows where to point an antenna without having to fire up an
|
||||||
|
// external tool.
|
||||||
|
|
||||||
|
const EARTH_KM = 6371.0088;
|
||||||
|
const EARTH_CIRCUMFERENCE_KM = 2 * Math.PI * EARTH_KM; // ≈ 40 030
|
||||||
|
|
||||||
|
// gridToLatLon parses a Maidenhead locator (4, 6, or 8 chars) and returns
|
||||||
|
// the center of the indicated square. Returns null on bad input.
|
||||||
|
export function gridToLatLon(grid: string): { lat: number; lon: number } | null {
|
||||||
|
if (!grid) return null;
|
||||||
|
const g = grid.trim().toUpperCase();
|
||||||
|
if (g.length < 4 || g.length % 2 !== 0) return null;
|
||||||
|
|
||||||
|
const A = 'A'.charCodeAt(0);
|
||||||
|
const Z = 'Z'.charCodeAt(0);
|
||||||
|
const isLetter = (c: number) => c >= A && c <= Z;
|
||||||
|
const isDigit = (c: string) => c >= '0' && c <= '9';
|
||||||
|
|
||||||
|
if (!isLetter(g.charCodeAt(0)) || !isLetter(g.charCodeAt(1))) return null;
|
||||||
|
if (!isDigit(g[2]) || !isDigit(g[3])) return null;
|
||||||
|
|
||||||
|
let lon = (g.charCodeAt(0) - A) * 20 - 180;
|
||||||
|
let lat = (g.charCodeAt(1) - A) * 10 - 90;
|
||||||
|
lon += parseInt(g[2], 10) * 2;
|
||||||
|
lat += parseInt(g[3], 10);
|
||||||
|
|
||||||
|
if (g.length >= 6) {
|
||||||
|
if (!isLetter(g.charCodeAt(4)) || !isLetter(g.charCodeAt(5))) return null;
|
||||||
|
lon += (g.charCodeAt(4) - A) * (2 / 24);
|
||||||
|
lat += (g.charCodeAt(5) - A) * (1 / 24);
|
||||||
|
// center of the sub-square
|
||||||
|
lon += 1 / 24;
|
||||||
|
lat += 0.5 / 24;
|
||||||
|
} else {
|
||||||
|
// center of the 2°×1° square
|
||||||
|
lon += 1;
|
||||||
|
lat += 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g.length >= 8) {
|
||||||
|
if (!isDigit(g[6]) || !isDigit(g[7])) return null;
|
||||||
|
// Extended grid (rare) — refine; using simple 10x subdivision.
|
||||||
|
lon += parseInt(g[6], 10) * (2 / 24 / 10) - 1 / 24;
|
||||||
|
lat += parseInt(g[7], 10) * (1 / 24 / 10) - 0.5 / 24;
|
||||||
|
}
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathInfo describes both short and long great-circle path between two
|
||||||
|
// points. Bearing in degrees from true north (0–360). Distance in km.
|
||||||
|
export interface PathInfo {
|
||||||
|
bearingShort: number;
|
||||||
|
bearingLong: number;
|
||||||
|
distanceShort: number;
|
||||||
|
distanceLong: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathBetween computes great-circle bearing+distance between two
|
||||||
|
// Maidenhead grids. Returns null if either is unparseable.
|
||||||
|
export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
|
||||||
|
const a = gridToLatLon(fromGrid);
|
||||||
|
const b = gridToLatLon(toGrid);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const φ1 = toRad(a.lat);
|
||||||
|
const φ2 = toRad(b.lat);
|
||||||
|
const Δλ = toRad(b.lon - a.lon);
|
||||||
|
|
||||||
|
// Spherical law of cosines is simpler than haversine and accurate enough
|
||||||
|
// for ham bearings (>1 km errors don't matter at the antenna-rotor level).
|
||||||
|
let cos = Math.sin(φ1) * Math.sin(φ2) + Math.cos(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
cos = Math.max(-1, Math.min(1, cos));
|
||||||
|
const distShort = EARTH_KM * Math.acos(cos);
|
||||||
|
|
||||||
|
// Forward azimuth.
|
||||||
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
let bearing = toDeg(Math.atan2(y, x));
|
||||||
|
bearing = (bearing + 360) % 360;
|
||||||
|
|
||||||
|
return {
|
||||||
|
bearingShort: bearing,
|
||||||
|
bearingLong: (bearing + 180) % 360,
|
||||||
|
distanceShort: distShort,
|
||||||
|
distanceLong: EARTH_CIRCUMFERENCE_KM - distShort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||||
|
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine class names: clsx for conditional values + tailwind-merge to
|
||||||
|
* resolve Tailwind conflicts (e.g. `px-2 px-4` → `px-4`). This is the
|
||||||
|
* `cn()` helper shadcn/ui uses everywhere.
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {createRoot} from 'react-dom/client'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
const container = document.getElementById('root')
|
||||||
|
|
||||||
|
const root = createRoot(container!)
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App/>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* External @imports must come first per CSS spec. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
/* ===== Cream & warm-orange palette =====
|
||||||
|
Background : warm cream (subtle beige, less cold than stone)
|
||||||
|
Accent : burnt orange (signal-lamp vibe, classic radio amateur)
|
||||||
|
Status : muted emerald / amber / rose
|
||||||
|
The BandSlotGrid cells keep their emerald+indigo independently — that
|
||||||
|
colour code conveys QSO status semantics, not app branding.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--color-background: #faf6ed; /* warm cream */
|
||||||
|
--color-foreground: #1c1917; /* stone-900 */
|
||||||
|
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #1c1917;
|
||||||
|
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #1c1917;
|
||||||
|
|
||||||
|
--color-primary: #c2410c; /* orange-700 — burnt orange */
|
||||||
|
--color-primary-foreground: #fff7ed;
|
||||||
|
|
||||||
|
--color-secondary: #f5efe0; /* warmer secondary surface */
|
||||||
|
--color-secondary-foreground: #1c1917;
|
||||||
|
|
||||||
|
--color-muted: #f5efe0;
|
||||||
|
--color-muted-foreground: #57534e; /* stone-600 */
|
||||||
|
|
||||||
|
--color-accent: #ffedd5; /* orange-100 — light cream-orange */
|
||||||
|
--color-accent-foreground: #9a3412; /* orange-800 */
|
||||||
|
|
||||||
|
--color-destructive: #b91c1c; /* red-700, classic brick */
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
|
||||||
|
--color-border: #e7dfd0; /* warm beige border */
|
||||||
|
--color-input: #e7dfd0;
|
||||||
|
--color-ring: #ea580c; /* orange-600 — focus ring */
|
||||||
|
|
||||||
|
--color-ok: #15803d; /* emerald-700 — slightly deeper */
|
||||||
|
--color-warn: #b45309; /* amber-700 */
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Cascadia Mono', Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* { @apply border-border; }
|
||||||
|
html, body, #root { height: 100%; }
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warm scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #d6cbb1;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid var(--color-background);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #b3a47d; }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Form-friendly views of the Wails-generated model classes.
|
||||||
|
//
|
||||||
|
// Wails generates each Go struct as a TS *class* with a hidden `convertValues`
|
||||||
|
// method used by the runtime for nested struct hydration. That method is
|
||||||
|
// irrelevant to our UI, but its presence means strict TS rejects any plain
|
||||||
|
// object literal we try to spread/assign back into the state setter.
|
||||||
|
//
|
||||||
|
// We strip it here so React component state stays usable with normal
|
||||||
|
// `{...prev, key: value}` patterns. Wails class instances remain assignable
|
||||||
|
// to these views since they have every required property.
|
||||||
|
|
||||||
|
import type { main, qso } from '../wailsjs/go/models';
|
||||||
|
|
||||||
|
export type LookupSettingsForm = Omit<main.LookupSettings, 'convertValues'>;
|
||||||
|
export type StationSettingsForm = Omit<main.StationSettings, 'convertValues'>;
|
||||||
|
export type ListsSettingsForm = Omit<main.ListsSettings, 'convertValues'>;
|
||||||
|
export type ModePresetForm = Omit<main.ModePreset, 'convertValues'>;
|
||||||
|
export type QSOForm = Omit<qso.QSO, 'convertValues'>;
|
||||||
|
export type WorkedBeforeView = Omit<qso.WorkedBefore, 'convertValues'>;
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
+78
@@ -0,0 +1,78 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {qso} from '../models';
|
||||||
|
import {profile} from '../models';
|
||||||
|
import {main} from '../models';
|
||||||
|
import {cat} from '../models';
|
||||||
|
import {adif} from '../models';
|
||||||
|
import {lookup} from '../models';
|
||||||
|
|
||||||
|
export function ActivateProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function ClearLookupCache():Promise<void>;
|
||||||
|
|
||||||
|
export function CountQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function DeleteAllQSO():Promise<number>;
|
||||||
|
|
||||||
|
export function DeleteProfile(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function GetCATSettings():Promise<main.CATSettings>;
|
||||||
|
|
||||||
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
|
|
||||||
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function ImportADIF(arg1:string):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
|
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||||
|
|
||||||
|
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCompactMode(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
|
|
||||||
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ActivateProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['ActivateProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddQSO(arg1) {
|
||||||
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearLookupCache() {
|
||||||
|
return window['go']['main']['App']['ClearLookupCache']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountQSO() {
|
||||||
|
return window['go']['main']['App']['CountQSO']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteAllQSO() {
|
||||||
|
return window['go']['main']['App']['DeleteAllQSO']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteQSO(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetActiveProfile() {
|
||||||
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetCATSettings() {
|
||||||
|
return window['go']['main']['App']['GetCATSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetCATState() {
|
||||||
|
return window['go']['main']['App']['GetCATState']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetCtyDatInfo() {
|
||||||
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetListsSettings() {
|
||||||
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetLookupSettings() {
|
||||||
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetQSO(arg1) {
|
||||||
|
return window['go']['main']['App']['GetQSO'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetStartupStatus() {
|
||||||
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetStationSettings() {
|
||||||
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportADIF(arg1) {
|
||||||
|
return window['go']['main']['App']['ImportADIF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListProfiles() {
|
||||||
|
return window['go']['main']['App']['ListProfiles']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListQSO(arg1) {
|
||||||
|
return window['go']['main']['App']['ListQSO'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LookupCallsign(arg1) {
|
||||||
|
return window['go']['main']['App']['LookupCallsign'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenADIFFile() {
|
||||||
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshCtyDat() {
|
||||||
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveCATSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveCATSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveListsSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveLookupSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveStationSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetCATFrequency(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCATFrequency'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetCATMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetCompactMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCompactMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchCATRig(arg1) {
|
||||||
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestLookupProvider(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateQSO(arg1) {
|
||||||
|
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkedBefore(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,776 @@
|
|||||||
|
export namespace adif {
|
||||||
|
|
||||||
|
export class ImportResult {
|
||||||
|
total: number;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ImportResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.total = source["total"];
|
||||||
|
this.imported = source["imported"];
|
||||||
|
this.skipped = source["skipped"];
|
||||||
|
this.errors = source["errors"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace cat {
|
||||||
|
|
||||||
|
export class RigState {
|
||||||
|
enabled: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
backend?: string;
|
||||||
|
rig_num?: number;
|
||||||
|
rig?: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
freq_rx_hz?: number;
|
||||||
|
split?: boolean;
|
||||||
|
mode?: string;
|
||||||
|
band?: string;
|
||||||
|
vfo?: string;
|
||||||
|
error?: string;
|
||||||
|
// Go type: time
|
||||||
|
updated_at?: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RigState(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.rig_num = source["rig_num"];
|
||||||
|
this.rig = source["rig"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.freq_rx_hz = source["freq_rx_hz"];
|
||||||
|
this.split = source["split"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.vfo = source["vfo"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace lookup {
|
||||||
|
|
||||||
|
export class Result {
|
||||||
|
callsign: string;
|
||||||
|
name?: string;
|
||||||
|
qth?: string;
|
||||||
|
address?: string;
|
||||||
|
state?: string;
|
||||||
|
cnty?: string;
|
||||||
|
country?: string;
|
||||||
|
grid?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
dxcc?: number;
|
||||||
|
cqz?: number;
|
||||||
|
ituz?: number;
|
||||||
|
cont?: string;
|
||||||
|
email?: string;
|
||||||
|
qsl_via?: string;
|
||||||
|
source: string;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Result(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.qth = source["qth"];
|
||||||
|
this.address = source["address"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.cnty = source["cnty"];
|
||||||
|
this.country = source["country"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.cqz = source["cqz"];
|
||||||
|
this.ituz = source["ituz"];
|
||||||
|
this.cont = source["cont"];
|
||||||
|
this.email = source["email"];
|
||||||
|
this.qsl_via = source["qsl_via"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace main {
|
||||||
|
|
||||||
|
export class CATSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
backend: string;
|
||||||
|
omnirig_rig: number;
|
||||||
|
poll_ms: number;
|
||||||
|
delay_ms: number;
|
||||||
|
digital_default: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CATSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.backend = source["backend"];
|
||||||
|
this.omnirig_rig = source["omnirig_rig"];
|
||||||
|
this.poll_ms = source["poll_ms"];
|
||||||
|
this.delay_ms = source["delay_ms"];
|
||||||
|
this.digital_default = source["digital_default"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class CtyDatInfo {
|
||||||
|
path: string;
|
||||||
|
entities: number;
|
||||||
|
loaded_at?: string;
|
||||||
|
file_mod_time?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CtyDatInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.path = source["path"];
|
||||||
|
this.entities = source["entities"];
|
||||||
|
this.loaded_at = source["loaded_at"];
|
||||||
|
this.file_mod_time = source["file_mod_time"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ModePreset {
|
||||||
|
name: string;
|
||||||
|
default_rst_sent?: string;
|
||||||
|
default_rst_rcvd?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ModePreset(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.name = source["name"];
|
||||||
|
this.default_rst_sent = source["default_rst_sent"];
|
||||||
|
this.default_rst_rcvd = source["default_rst_rcvd"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ListsSettings {
|
||||||
|
bands: string[];
|
||||||
|
modes: ModePreset[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ListsSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = this.convertValues(source["modes"], ModePreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class LookupSettings {
|
||||||
|
qrz_user: string;
|
||||||
|
qrz_password: string;
|
||||||
|
hamqth_user: string;
|
||||||
|
hamqth_password: string;
|
||||||
|
primary: string;
|
||||||
|
failsafe: string;
|
||||||
|
cache_ttl_days: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LookupSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.qrz_user = source["qrz_user"];
|
||||||
|
this.qrz_password = source["qrz_password"];
|
||||||
|
this.hamqth_user = source["hamqth_user"];
|
||||||
|
this.hamqth_password = source["hamqth_password"];
|
||||||
|
this.primary = source["primary"];
|
||||||
|
this.failsafe = source["failsafe"];
|
||||||
|
this.cache_ttl_days = source["cache_ttl_days"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StartupStatus {
|
||||||
|
ok: boolean;
|
||||||
|
err: string;
|
||||||
|
db_path: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StartupStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.err = source["err"];
|
||||||
|
this.db_path = source["db_path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StationSettings {
|
||||||
|
callsign: string;
|
||||||
|
operator: string;
|
||||||
|
my_grid: string;
|
||||||
|
my_country: string;
|
||||||
|
my_sota_ref: string;
|
||||||
|
my_pota_ref: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.my_grid = source["my_grid"];
|
||||||
|
this.my_country = source["my_country"];
|
||||||
|
this.my_sota_ref = source["my_sota_ref"];
|
||||||
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace profile {
|
||||||
|
|
||||||
|
export class Profile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
callsign: string;
|
||||||
|
operator: string;
|
||||||
|
my_grid: string;
|
||||||
|
my_country: string;
|
||||||
|
my_state: string;
|
||||||
|
my_cnty: string;
|
||||||
|
my_street: string;
|
||||||
|
my_city: string;
|
||||||
|
my_postal_code: string;
|
||||||
|
my_sota_ref: string;
|
||||||
|
my_pota_ref: string;
|
||||||
|
my_rig: string;
|
||||||
|
my_antenna: string;
|
||||||
|
tx_pwr?: number;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
// Go type: time
|
||||||
|
created_at: any;
|
||||||
|
// Go type: time
|
||||||
|
updated_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Profile(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.my_grid = source["my_grid"];
|
||||||
|
this.my_country = source["my_country"];
|
||||||
|
this.my_state = source["my_state"];
|
||||||
|
this.my_cnty = source["my_cnty"];
|
||||||
|
this.my_street = source["my_street"];
|
||||||
|
this.my_city = source["my_city"];
|
||||||
|
this.my_postal_code = source["my_postal_code"];
|
||||||
|
this.my_sota_ref = source["my_sota_ref"];
|
||||||
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
|
this.my_rig = source["my_rig"];
|
||||||
|
this.my_antenna = source["my_antenna"];
|
||||||
|
this.tx_pwr = source["tx_pwr"];
|
||||||
|
this.is_active = source["is_active"];
|
||||||
|
this.sort_order = source["sort_order"];
|
||||||
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace qso {
|
||||||
|
|
||||||
|
export class BandMode {
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandMode(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class BandStatus {
|
||||||
|
band: string;
|
||||||
|
class: string;
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.class = source["class"];
|
||||||
|
this.status = source["status"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ListFilter {
|
||||||
|
callsign?: string;
|
||||||
|
band?: string;
|
||||||
|
mode?: string;
|
||||||
|
station_callsign?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ListFilter(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.station_callsign = source["station_callsign"];
|
||||||
|
this.limit = source["limit"];
|
||||||
|
this.offset = source["offset"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class QSO {
|
||||||
|
id: number;
|
||||||
|
callsign: string;
|
||||||
|
// Go type: time
|
||||||
|
qso_date: any;
|
||||||
|
// Go type: time
|
||||||
|
qso_date_off?: any;
|
||||||
|
band: string;
|
||||||
|
band_rx?: string;
|
||||||
|
mode: string;
|
||||||
|
submode?: string;
|
||||||
|
freq_hz?: number;
|
||||||
|
freq_rx_hz?: number;
|
||||||
|
rst_sent?: string;
|
||||||
|
rst_rcvd?: string;
|
||||||
|
name?: string;
|
||||||
|
qth?: string;
|
||||||
|
address?: string;
|
||||||
|
email?: string;
|
||||||
|
web?: string;
|
||||||
|
grid?: string;
|
||||||
|
gridsquare_ext?: string;
|
||||||
|
vucc_grids?: string;
|
||||||
|
country?: string;
|
||||||
|
state?: string;
|
||||||
|
cnty?: string;
|
||||||
|
dxcc?: number;
|
||||||
|
cont?: string;
|
||||||
|
cqz?: number;
|
||||||
|
ituz?: number;
|
||||||
|
iota?: string;
|
||||||
|
sota_ref?: string;
|
||||||
|
pota_ref?: string;
|
||||||
|
age?: number;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
rig?: string;
|
||||||
|
ant?: string;
|
||||||
|
qsl_sent?: string;
|
||||||
|
qsl_rcvd?: string;
|
||||||
|
qsl_sent_date?: string;
|
||||||
|
qsl_rcvd_date?: string;
|
||||||
|
qsl_via?: string;
|
||||||
|
qsl_msg?: string;
|
||||||
|
qslmsg_rcvd?: string;
|
||||||
|
lotw_sent?: string;
|
||||||
|
lotw_rcvd?: string;
|
||||||
|
lotw_sent_date?: string;
|
||||||
|
lotw_rcvd_date?: string;
|
||||||
|
eqsl_sent?: string;
|
||||||
|
eqsl_rcvd?: string;
|
||||||
|
eqsl_sent_date?: string;
|
||||||
|
eqsl_rcvd_date?: string;
|
||||||
|
clublog_qso_upload_date?: string;
|
||||||
|
clublog_qso_upload_status?: string;
|
||||||
|
hrdlog_qso_upload_date?: string;
|
||||||
|
hrdlog_qso_upload_status?: string;
|
||||||
|
contest_id?: string;
|
||||||
|
srx?: number;
|
||||||
|
stx?: number;
|
||||||
|
srx_string?: string;
|
||||||
|
stx_string?: string;
|
||||||
|
check?: string;
|
||||||
|
precedence?: string;
|
||||||
|
arrl_sect?: string;
|
||||||
|
prop_mode?: string;
|
||||||
|
sat_name?: string;
|
||||||
|
sat_mode?: string;
|
||||||
|
ant_az?: number;
|
||||||
|
ant_el?: number;
|
||||||
|
ant_path?: string;
|
||||||
|
station_callsign?: string;
|
||||||
|
operator?: string;
|
||||||
|
my_grid?: string;
|
||||||
|
my_gridsquare_ext?: string;
|
||||||
|
my_country?: string;
|
||||||
|
my_state?: string;
|
||||||
|
my_cnty?: string;
|
||||||
|
my_iota?: string;
|
||||||
|
my_sota_ref?: string;
|
||||||
|
my_pota_ref?: string;
|
||||||
|
my_dxcc?: number;
|
||||||
|
my_cq_zone?: number;
|
||||||
|
my_itu_zone?: number;
|
||||||
|
my_lat?: number;
|
||||||
|
my_lon?: number;
|
||||||
|
my_street?: string;
|
||||||
|
my_city?: string;
|
||||||
|
my_postal_code?: string;
|
||||||
|
my_rig?: string;
|
||||||
|
my_antenna?: string;
|
||||||
|
tx_pwr?: number;
|
||||||
|
comment?: string;
|
||||||
|
notes?: string;
|
||||||
|
extras?: Record<string, string>;
|
||||||
|
// Go type: time
|
||||||
|
created_at: any;
|
||||||
|
// Go type: time
|
||||||
|
updated_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSO(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.qso_date = this.convertValues(source["qso_date"], null);
|
||||||
|
this.qso_date_off = this.convertValues(source["qso_date_off"], null);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.band_rx = source["band_rx"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.submode = source["submode"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.freq_rx_hz = source["freq_rx_hz"];
|
||||||
|
this.rst_sent = source["rst_sent"];
|
||||||
|
this.rst_rcvd = source["rst_rcvd"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.qth = source["qth"];
|
||||||
|
this.address = source["address"];
|
||||||
|
this.email = source["email"];
|
||||||
|
this.web = source["web"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.gridsquare_ext = source["gridsquare_ext"];
|
||||||
|
this.vucc_grids = source["vucc_grids"];
|
||||||
|
this.country = source["country"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.cnty = source["cnty"];
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.cont = source["cont"];
|
||||||
|
this.cqz = source["cqz"];
|
||||||
|
this.ituz = source["ituz"];
|
||||||
|
this.iota = source["iota"];
|
||||||
|
this.sota_ref = source["sota_ref"];
|
||||||
|
this.pota_ref = source["pota_ref"];
|
||||||
|
this.age = source["age"];
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.rig = source["rig"];
|
||||||
|
this.ant = source["ant"];
|
||||||
|
this.qsl_sent = source["qsl_sent"];
|
||||||
|
this.qsl_rcvd = source["qsl_rcvd"];
|
||||||
|
this.qsl_sent_date = source["qsl_sent_date"];
|
||||||
|
this.qsl_rcvd_date = source["qsl_rcvd_date"];
|
||||||
|
this.qsl_via = source["qsl_via"];
|
||||||
|
this.qsl_msg = source["qsl_msg"];
|
||||||
|
this.qslmsg_rcvd = source["qslmsg_rcvd"];
|
||||||
|
this.lotw_sent = source["lotw_sent"];
|
||||||
|
this.lotw_rcvd = source["lotw_rcvd"];
|
||||||
|
this.lotw_sent_date = source["lotw_sent_date"];
|
||||||
|
this.lotw_rcvd_date = source["lotw_rcvd_date"];
|
||||||
|
this.eqsl_sent = source["eqsl_sent"];
|
||||||
|
this.eqsl_rcvd = source["eqsl_rcvd"];
|
||||||
|
this.eqsl_sent_date = source["eqsl_sent_date"];
|
||||||
|
this.eqsl_rcvd_date = source["eqsl_rcvd_date"];
|
||||||
|
this.clublog_qso_upload_date = source["clublog_qso_upload_date"];
|
||||||
|
this.clublog_qso_upload_status = source["clublog_qso_upload_status"];
|
||||||
|
this.hrdlog_qso_upload_date = source["hrdlog_qso_upload_date"];
|
||||||
|
this.hrdlog_qso_upload_status = source["hrdlog_qso_upload_status"];
|
||||||
|
this.contest_id = source["contest_id"];
|
||||||
|
this.srx = source["srx"];
|
||||||
|
this.stx = source["stx"];
|
||||||
|
this.srx_string = source["srx_string"];
|
||||||
|
this.stx_string = source["stx_string"];
|
||||||
|
this.check = source["check"];
|
||||||
|
this.precedence = source["precedence"];
|
||||||
|
this.arrl_sect = source["arrl_sect"];
|
||||||
|
this.prop_mode = source["prop_mode"];
|
||||||
|
this.sat_name = source["sat_name"];
|
||||||
|
this.sat_mode = source["sat_mode"];
|
||||||
|
this.ant_az = source["ant_az"];
|
||||||
|
this.ant_el = source["ant_el"];
|
||||||
|
this.ant_path = source["ant_path"];
|
||||||
|
this.station_callsign = source["station_callsign"];
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.my_grid = source["my_grid"];
|
||||||
|
this.my_gridsquare_ext = source["my_gridsquare_ext"];
|
||||||
|
this.my_country = source["my_country"];
|
||||||
|
this.my_state = source["my_state"];
|
||||||
|
this.my_cnty = source["my_cnty"];
|
||||||
|
this.my_iota = source["my_iota"];
|
||||||
|
this.my_sota_ref = source["my_sota_ref"];
|
||||||
|
this.my_pota_ref = source["my_pota_ref"];
|
||||||
|
this.my_dxcc = source["my_dxcc"];
|
||||||
|
this.my_cq_zone = source["my_cq_zone"];
|
||||||
|
this.my_itu_zone = source["my_itu_zone"];
|
||||||
|
this.my_lat = source["my_lat"];
|
||||||
|
this.my_lon = source["my_lon"];
|
||||||
|
this.my_street = source["my_street"];
|
||||||
|
this.my_city = source["my_city"];
|
||||||
|
this.my_postal_code = source["my_postal_code"];
|
||||||
|
this.my_rig = source["my_rig"];
|
||||||
|
this.my_antenna = source["my_antenna"];
|
||||||
|
this.tx_pwr = source["tx_pwr"];
|
||||||
|
this.comment = source["comment"];
|
||||||
|
this.notes = source["notes"];
|
||||||
|
this.extras = source["extras"];
|
||||||
|
this.created_at = this.convertValues(source["created_at"], null);
|
||||||
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class WorkedEntry {
|
||||||
|
id: number;
|
||||||
|
// Go type: time
|
||||||
|
qso_date: any;
|
||||||
|
band: string;
|
||||||
|
mode: string;
|
||||||
|
rst_sent?: string;
|
||||||
|
rst_rcvd?: string;
|
||||||
|
qsl_sent?: string;
|
||||||
|
qsl_rcvd?: string;
|
||||||
|
lotw_sent?: string;
|
||||||
|
lotw_rcvd?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WorkedEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.qso_date = this.convertValues(source["qso_date"], null);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.rst_sent = source["rst_sent"];
|
||||||
|
this.rst_rcvd = source["rst_rcvd"];
|
||||||
|
this.qsl_sent = source["qsl_sent"];
|
||||||
|
this.qsl_rcvd = source["qsl_rcvd"];
|
||||||
|
this.lotw_sent = source["lotw_sent"];
|
||||||
|
this.lotw_rcvd = source["lotw_rcvd"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class WorkedBefore {
|
||||||
|
callsign: string;
|
||||||
|
count: number;
|
||||||
|
// Go type: time
|
||||||
|
first?: any;
|
||||||
|
// Go type: time
|
||||||
|
last?: any;
|
||||||
|
bands: string[];
|
||||||
|
modes: string[];
|
||||||
|
band_modes: BandMode[];
|
||||||
|
entries: WorkedEntry[];
|
||||||
|
dxcc?: number;
|
||||||
|
dxcc_name?: string;
|
||||||
|
dxcc_count: number;
|
||||||
|
// Go type: time
|
||||||
|
dxcc_first?: any;
|
||||||
|
// Go type: time
|
||||||
|
dxcc_last?: any;
|
||||||
|
dxcc_bands: string[];
|
||||||
|
dxcc_modes: string[];
|
||||||
|
dxcc_band_modes: BandMode[];
|
||||||
|
band_status: BandStatus[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new WorkedBefore(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.callsign = source["callsign"];
|
||||||
|
this.count = source["count"];
|
||||||
|
this.first = this.convertValues(source["first"], null);
|
||||||
|
this.last = this.convertValues(source["last"], null);
|
||||||
|
this.bands = source["bands"];
|
||||||
|
this.modes = source["modes"];
|
||||||
|
this.band_modes = this.convertValues(source["band_modes"], BandMode);
|
||||||
|
this.entries = this.convertValues(source["entries"], WorkedEntry);
|
||||||
|
this.dxcc = source["dxcc"];
|
||||||
|
this.dxcc_name = source["dxcc_name"];
|
||||||
|
this.dxcc_count = source["dxcc_count"];
|
||||||
|
this.dxcc_first = this.convertValues(source["dxcc_first"], null);
|
||||||
|
this.dxcc_last = this.convertValues(source["dxcc_last"], null);
|
||||||
|
this.dxcc_bands = source["dxcc_bands"];
|
||||||
|
this.dxcc_modes = source["dxcc_modes"];
|
||||||
|
this.dxcc_band_modes = this.convertValues(source["dxcc_band_modes"], BandMode);
|
||||||
|
this.band_status = this.convertValues(source["band_status"], BandStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@wailsapp/runtime",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Wails Javascript runtime library",
|
||||||
|
"main": "runtime.js",
|
||||||
|
"types": "runtime.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wailsapp/wails.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"Wails",
|
||||||
|
"Javascript",
|
||||||
|
"Go"
|
||||||
|
],
|
||||||
|
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/wailsapp/wails/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Screen {
|
||||||
|
isCurrent: boolean;
|
||||||
|
isPrimary: boolean;
|
||||||
|
width : number
|
||||||
|
height : number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment information such as platform, buildtype, ...
|
||||||
|
export interface EnvironmentInfo {
|
||||||
|
buildType: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||||
|
// emits the given event. Optional data may be passed with the event.
|
||||||
|
// This will trigger any event listeners.
|
||||||
|
export function EventsEmit(eventName: string, ...data: any): void;
|
||||||
|
|
||||||
|
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||||
|
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||||
|
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||||
|
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||||
|
|
||||||
|
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||||
|
// sets up a listener for the given event name, but will only trigger once.
|
||||||
|
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||||
|
|
||||||
|
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||||
|
// unregisters the listener for the given event name.
|
||||||
|
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||||
|
|
||||||
|
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||||
|
// unregisters all listeners.
|
||||||
|
export function EventsOffAll(): void;
|
||||||
|
|
||||||
|
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||||
|
// logs the given message as a raw message
|
||||||
|
export function LogPrint(message: string): void;
|
||||||
|
|
||||||
|
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||||
|
// logs the given message at the `trace` log level.
|
||||||
|
export function LogTrace(message: string): void;
|
||||||
|
|
||||||
|
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||||
|
// logs the given message at the `debug` log level.
|
||||||
|
export function LogDebug(message: string): void;
|
||||||
|
|
||||||
|
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||||
|
// logs the given message at the `error` log level.
|
||||||
|
export function LogError(message: string): void;
|
||||||
|
|
||||||
|
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||||
|
// logs the given message at the `fatal` log level.
|
||||||
|
// The application will quit after calling this method.
|
||||||
|
export function LogFatal(message: string): void;
|
||||||
|
|
||||||
|
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||||
|
// logs the given message at the `info` log level.
|
||||||
|
export function LogInfo(message: string): void;
|
||||||
|
|
||||||
|
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||||
|
// logs the given message at the `warning` log level.
|
||||||
|
export function LogWarning(message: string): void;
|
||||||
|
|
||||||
|
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||||
|
// Forces a reload by the main application as well as connected browsers.
|
||||||
|
export function WindowReload(): void;
|
||||||
|
|
||||||
|
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||||
|
// Reloads the application frontend.
|
||||||
|
export function WindowReloadApp(): void;
|
||||||
|
|
||||||
|
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||||
|
// Sets the window AlwaysOnTop or not on top.
|
||||||
|
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||||
|
|
||||||
|
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window theme to system default (dark/light).
|
||||||
|
export function WindowSetSystemDefaultTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to light theme.
|
||||||
|
export function WindowSetLightTheme(): void;
|
||||||
|
|
||||||
|
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||||
|
// *Windows only*
|
||||||
|
// Sets window to dark theme.
|
||||||
|
export function WindowSetDarkTheme(): void;
|
||||||
|
|
||||||
|
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||||
|
// Centers the window on the monitor the window is currently on.
|
||||||
|
export function WindowCenter(): void;
|
||||||
|
|
||||||
|
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||||
|
// Sets the text in the window title bar.
|
||||||
|
export function WindowSetTitle(title: string): void;
|
||||||
|
|
||||||
|
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||||
|
// Makes the window full screen.
|
||||||
|
export function WindowFullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||||
|
// Restores the previous window dimensions and position prior to full screen.
|
||||||
|
export function WindowUnfullscreen(): void;
|
||||||
|
|
||||||
|
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||||
|
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||||
|
export function WindowIsFullscreen(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||||
|
// Sets the width and height of the window.
|
||||||
|
export function WindowSetSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||||
|
// Gets the width and height of the window.
|
||||||
|
export function WindowGetSize(): Promise<Size>;
|
||||||
|
|
||||||
|
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||||
|
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMaxSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||||
|
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||||
|
// Setting a size of 0,0 will disable this constraint.
|
||||||
|
export function WindowSetMinSize(width: number, height: number): void;
|
||||||
|
|
||||||
|
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||||
|
// Sets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowSetPosition(x: number, y: number): void;
|
||||||
|
|
||||||
|
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||||
|
// Gets the window position relative to the monitor the window is currently on.
|
||||||
|
export function WindowGetPosition(): Promise<Position>;
|
||||||
|
|
||||||
|
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||||
|
// Hides the window.
|
||||||
|
export function WindowHide(): void;
|
||||||
|
|
||||||
|
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||||
|
// Shows the window, if it is currently hidden.
|
||||||
|
export function WindowShow(): void;
|
||||||
|
|
||||||
|
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||||
|
// Maximises the window to fill the screen.
|
||||||
|
export function WindowMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||||
|
// Toggles between Maximised and UnMaximised.
|
||||||
|
export function WindowToggleMaximise(): void;
|
||||||
|
|
||||||
|
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||||
|
// Restores the window to the dimensions and position prior to maximising.
|
||||||
|
export function WindowUnmaximise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||||
|
export function WindowIsMaximised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||||
|
// Minimises the window.
|
||||||
|
export function WindowMinimise(): void;
|
||||||
|
|
||||||
|
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||||
|
// Restores the window to the dimensions and position prior to minimising.
|
||||||
|
export function WindowUnminimise(): void;
|
||||||
|
|
||||||
|
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||||
|
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||||
|
export function WindowIsMinimised(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||||
|
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||||
|
export function WindowIsNormal(): Promise<boolean>;
|
||||||
|
|
||||||
|
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||||
|
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||||
|
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||||
|
|
||||||
|
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||||
|
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||||
|
export function ScreenGetAll(): Promise<Screen[]>;
|
||||||
|
|
||||||
|
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||||
|
// Opens the given URL in the system browser.
|
||||||
|
export function BrowserOpenURL(url: string): void;
|
||||||
|
|
||||||
|
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||||
|
// Returns information about the environment
|
||||||
|
export function Environment(): Promise<EnvironmentInfo>;
|
||||||
|
|
||||||
|
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||||
|
// Quits the application.
|
||||||
|
export function Quit(): void;
|
||||||
|
|
||||||
|
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||||
|
// Hides the application.
|
||||||
|
export function Hide(): void;
|
||||||
|
|
||||||
|
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||||
|
// Shows the application.
|
||||||
|
export function Show(): void;
|
||||||
|
|
||||||
|
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||||
|
// Returns the current text stored on clipboard
|
||||||
|
export function ClipboardGetText(): Promise<string>;
|
||||||
|
|
||||||
|
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||||
|
// Sets a text on the clipboard
|
||||||
|
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||||
|
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||||
|
|
||||||
|
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||||
|
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
export function OnFileDropOff() :void
|
||||||
|
|
||||||
|
// Check if the file path resolver is available
|
||||||
|
export function CanResolveFilePaths(): boolean;
|
||||||
|
|
||||||
|
// Resolves file paths for an array of files
|
||||||
|
export function ResolveFilePaths(files: File[]): void
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/*
|
||||||
|
_ __ _ __
|
||||||
|
| | / /___ _(_) /____
|
||||||
|
| | /| / / __ `/ / / ___/
|
||||||
|
| |/ |/ / /_/ / / (__ )
|
||||||
|
|__/|__/\__,_/_/_/____/
|
||||||
|
The electron alternative for Go
|
||||||
|
(c) Lea Anthony 2019-present
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function LogPrint(message) {
|
||||||
|
window.runtime.LogPrint(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogTrace(message) {
|
||||||
|
window.runtime.LogTrace(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogDebug(message) {
|
||||||
|
window.runtime.LogDebug(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogInfo(message) {
|
||||||
|
window.runtime.LogInfo(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogWarning(message) {
|
||||||
|
window.runtime.LogWarning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogError(message) {
|
||||||
|
window.runtime.LogError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogFatal(message) {
|
||||||
|
window.runtime.LogFatal(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||||
|
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOn(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOff(eventName, ...additionalEventNames) {
|
||||||
|
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOffAll() {
|
||||||
|
return window.runtime.EventsOffAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsOnce(eventName, callback) {
|
||||||
|
return EventsOnMultiple(eventName, callback, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsEmit(eventName) {
|
||||||
|
let args = [eventName].slice.call(arguments);
|
||||||
|
return window.runtime.EventsEmit.apply(null, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReload() {
|
||||||
|
window.runtime.WindowReload();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowReloadApp() {
|
||||||
|
window.runtime.WindowReloadApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetAlwaysOnTop(b) {
|
||||||
|
window.runtime.WindowSetAlwaysOnTop(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSystemDefaultTheme() {
|
||||||
|
window.runtime.WindowSetSystemDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetLightTheme() {
|
||||||
|
window.runtime.WindowSetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetDarkTheme() {
|
||||||
|
window.runtime.WindowSetDarkTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowCenter() {
|
||||||
|
window.runtime.WindowCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetTitle(title) {
|
||||||
|
window.runtime.WindowSetTitle(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowFullscreen() {
|
||||||
|
window.runtime.WindowFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnfullscreen() {
|
||||||
|
window.runtime.WindowUnfullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsFullscreen() {
|
||||||
|
return window.runtime.WindowIsFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetSize() {
|
||||||
|
return window.runtime.WindowGetSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetSize(width, height) {
|
||||||
|
window.runtime.WindowSetSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMaxSize(width, height) {
|
||||||
|
window.runtime.WindowSetMaxSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetMinSize(width, height) {
|
||||||
|
window.runtime.WindowSetMinSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetPosition(x, y) {
|
||||||
|
window.runtime.WindowSetPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowGetPosition() {
|
||||||
|
return window.runtime.WindowGetPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowHide() {
|
||||||
|
window.runtime.WindowHide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowShow() {
|
||||||
|
window.runtime.WindowShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMaximise() {
|
||||||
|
window.runtime.WindowMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowToggleMaximise() {
|
||||||
|
window.runtime.WindowToggleMaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnmaximise() {
|
||||||
|
window.runtime.WindowUnmaximise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMaximised() {
|
||||||
|
return window.runtime.WindowIsMaximised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowMinimise() {
|
||||||
|
window.runtime.WindowMinimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowUnminimise() {
|
||||||
|
window.runtime.WindowUnminimise();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||||
|
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScreenGetAll() {
|
||||||
|
return window.runtime.ScreenGetAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsMinimised() {
|
||||||
|
return window.runtime.WindowIsMinimised();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WindowIsNormal() {
|
||||||
|
return window.runtime.WindowIsNormal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserOpenURL(url) {
|
||||||
|
window.runtime.BrowserOpenURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Environment() {
|
||||||
|
return window.runtime.Environment();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Quit() {
|
||||||
|
window.runtime.Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Hide() {
|
||||||
|
window.runtime.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show() {
|
||||||
|
window.runtime.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardGetText() {
|
||||||
|
return window.runtime.ClipboardGetText();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClipboardSetText(text) {
|
||||||
|
return window.runtime.ClipboardSetText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @callback OnFileDropCallback
|
||||||
|
* @param {number} x - x coordinate of the drop
|
||||||
|
* @param {number} y - y coordinate of the drop
|
||||||
|
* @param {string[]} paths - A list of file paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||||
|
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||||
|
*/
|
||||||
|
export function OnFileDrop(callback, useDropTarget) {
|
||||||
|
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||||
|
*/
|
||||||
|
export function OnFileDropOff() {
|
||||||
|
return window.runtime.OnFileDropOff();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanResolveFilePaths() {
|
||||||
|
return window.runtime.CanResolveFilePaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolveFilePaths(files) {
|
||||||
|
return window.runtime.ResolveFilePaths(files);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module hamlog
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ole/go-ole v1.3.0
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
modernc.org/sqlite v1.50.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
// replace github.com/wailsapp/wails/v2 v2.11.0 => C:\Users\rouggy.ROUGGY\go\pkg\mod
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||||
|
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||||
|
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||||
|
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||||
|
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportResult summarises an ADIF import for the UI.
|
||||||
|
type ImportResult struct {
|
||||||
|
Total int `json:"total"` // records found in the file
|
||||||
|
Imported int `json:"imported"` // successfully inserted
|
||||||
|
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
|
||||||
|
Errors []string `json:"errors"` // up to maxErrors error messages
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxErrors = 50
|
||||||
|
|
||||||
|
// Importer streams an ADI file into a QSO repository.
|
||||||
|
type Importer struct {
|
||||||
|
Repo *qso.Repo
|
||||||
|
BatchSize int // 0 → 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportFile opens the file at path and imports it into the repo.
|
||||||
|
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return im.Import(ctx, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import streams the ADI content from r into the repo.
|
||||||
|
func (im *Importer) Import(ctx context.Context, r interface {
|
||||||
|
Read(p []byte) (int, error)
|
||||||
|
}) (ImportResult, error) {
|
||||||
|
if im.BatchSize <= 0 {
|
||||||
|
im.BatchSize = 500
|
||||||
|
}
|
||||||
|
res := ImportResult{}
|
||||||
|
batch := make([]qso.QSO, 0, im.BatchSize)
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := im.Repo.AddBatch(ctx, batch)
|
||||||
|
res.Imported += int(n)
|
||||||
|
batch = batch[:0]
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Parse(r, func(rec Record) error {
|
||||||
|
res.Total++
|
||||||
|
q, ok := recordToQSO(rec)
|
||||||
|
if !ok {
|
||||||
|
res.Skipped++
|
||||||
|
if len(res.Errors) < maxErrors {
|
||||||
|
res.Errors = append(res.Errors,
|
||||||
|
fmt.Sprintf("record %d: missing required fields (call/band/mode/date)", res.Total))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
batch = append(batch, q)
|
||||||
|
if len(batch) >= im.BatchSize {
|
||||||
|
return flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = flush()
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
|
||||||
|
// Anything not in this set ends up in Extras.
|
||||||
|
var adifPromoted = stringSet(
|
||||||
|
// Core
|
||||||
|
"call", "qso_date", "time_on", "qso_date_off", "time_off",
|
||||||
|
"band", "band_rx", "mode", "submode", "freq", "freq_rx",
|
||||||
|
"rst_sent", "rst_rcvd",
|
||||||
|
// Contacted
|
||||||
|
"name", "qth", "address", "email", "web",
|
||||||
|
"gridsquare", "gridsquare_ext", "vucc_grids",
|
||||||
|
"country", "state", "cnty",
|
||||||
|
"dxcc", "cont", "cqz", "ituz",
|
||||||
|
"iota", "sota_ref", "pota_ref",
|
||||||
|
"age", "lat", "lon", "rig", "ant",
|
||||||
|
// QSL
|
||||||
|
"qsl_sent", "qsl_rcvd",
|
||||||
|
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
|
||||||
|
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
|
||||||
|
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
|
||||||
|
"clublog_qso_upload_date", "clublog_qso_upload_status",
|
||||||
|
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
|
||||||
|
// Contest
|
||||||
|
"contest_id", "srx", "stx", "srx_string", "stx_string",
|
||||||
|
"check", "precedence", "arrl_sect",
|
||||||
|
// Sat / propagation
|
||||||
|
"prop_mode", "sat_name", "sat_mode", "ant_az", "ant_el", "ant_path",
|
||||||
|
// My station
|
||||||
|
"station_callsign", "operator",
|
||||||
|
"my_gridsquare", "my_gridsquare_ext", "my_country", "my_state", "my_cnty", "my_iota",
|
||||||
|
"my_sota_ref", "my_pota_ref",
|
||||||
|
"my_dxcc", "my_cq_zone", "my_itu_zone", "my_lat", "my_lon",
|
||||||
|
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
|
||||||
|
// Misc
|
||||||
|
"tx_pwr", "comment", "notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
func stringSet(items ...string) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{}, len(items))
|
||||||
|
for _, s := range items {
|
||||||
|
m[s] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||||
|
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
|
||||||
|
func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(rec["call"]))
|
||||||
|
if call == "" {
|
||||||
|
return qso.QSO{}, false
|
||||||
|
}
|
||||||
|
band := strings.ToLower(strings.TrimSpace(rec["band"]))
|
||||||
|
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
|
||||||
|
date := parseDateTime(rec["qso_date"], rec["time_on"])
|
||||||
|
if date.IsZero() || band == "" || mode == "" {
|
||||||
|
return qso.QSO{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
q := qso.QSO{
|
||||||
|
Callsign: call,
|
||||||
|
QSODate: date,
|
||||||
|
QSODateOff: parseDateTime(rec["qso_date_off"], rec["time_off"]),
|
||||||
|
Band: band,
|
||||||
|
BandRX: strings.ToLower(rec["band_rx"]),
|
||||||
|
Mode: mode,
|
||||||
|
Submode: strings.ToUpper(rec["submode"]),
|
||||||
|
}
|
||||||
|
if hz, ok := parseFreqHz(rec["freq"]); ok {
|
||||||
|
q.FreqHz = &hz
|
||||||
|
}
|
||||||
|
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
|
||||||
|
q.FreqRXHz = &hz
|
||||||
|
}
|
||||||
|
|
||||||
|
q.RSTSent = rec["rst_sent"]
|
||||||
|
q.RSTRcvd = rec["rst_rcvd"]
|
||||||
|
|
||||||
|
// Contacted station
|
||||||
|
q.Name = rec["name"]
|
||||||
|
q.QTH = rec["qth"]
|
||||||
|
q.Address = rec["address"]
|
||||||
|
q.Email = rec["email"]
|
||||||
|
q.Web = rec["web"]
|
||||||
|
q.Grid = strings.ToUpper(rec["gridsquare"])
|
||||||
|
q.GridExt = strings.ToUpper(rec["gridsquare_ext"])
|
||||||
|
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
|
||||||
|
q.Country = rec["country"]
|
||||||
|
q.State = strings.ToUpper(rec["state"])
|
||||||
|
q.County = rec["cnty"]
|
||||||
|
if v, ok := parseInt(rec["dxcc"]); ok {
|
||||||
|
q.DXCC = &v
|
||||||
|
}
|
||||||
|
q.Continent = strings.ToUpper(rec["cont"])
|
||||||
|
if v, ok := parseInt(rec["cqz"]); ok {
|
||||||
|
q.CQZ = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseInt(rec["ituz"]); ok {
|
||||||
|
q.ITUZ = &v
|
||||||
|
}
|
||||||
|
q.IOTA = strings.ToUpper(rec["iota"])
|
||||||
|
q.SOTARef = strings.ToUpper(rec["sota_ref"])
|
||||||
|
q.POTARef = strings.ToUpper(rec["pota_ref"])
|
||||||
|
if v, ok := parseInt(rec["age"]); ok {
|
||||||
|
q.Age = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["lat"]); ok {
|
||||||
|
q.Lat = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["lon"]); ok {
|
||||||
|
q.Lon = &v
|
||||||
|
}
|
||||||
|
q.Rig = rec["rig"]
|
||||||
|
q.Ant = rec["ant"]
|
||||||
|
|
||||||
|
// QSL
|
||||||
|
q.QSLSent = rec["qsl_sent"]
|
||||||
|
q.QSLRcvd = rec["qsl_rcvd"]
|
||||||
|
q.QSLSentDate = rec["qslsdate"]
|
||||||
|
q.QSLRcvdDate = rec["qslrdate"]
|
||||||
|
q.QSLVia = rec["qsl_via"]
|
||||||
|
q.QSLMsg = rec["qslmsg"]
|
||||||
|
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
|
||||||
|
q.LOTWSent = rec["lotw_qsl_sent"]
|
||||||
|
q.LOTWRcvd = rec["lotw_qsl_rcvd"]
|
||||||
|
q.LOTWSentDate = rec["lotw_qslsdate"]
|
||||||
|
q.LOTWRcvdDate = rec["lotw_qslrdate"]
|
||||||
|
q.EQSLSent = rec["eqsl_qsl_sent"]
|
||||||
|
q.EQSLRcvd = rec["eqsl_qsl_rcvd"]
|
||||||
|
q.EQSLSentDate = rec["eqsl_qslsdate"]
|
||||||
|
q.EQSLRcvdDate = rec["eqsl_qslrdate"]
|
||||||
|
q.ClublogUploadDate = rec["clublog_qso_upload_date"]
|
||||||
|
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
|
||||||
|
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
|
||||||
|
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
|
||||||
|
|
||||||
|
// Contest
|
||||||
|
q.ContestID = rec["contest_id"]
|
||||||
|
if v, ok := parseInt(rec["srx"]); ok {
|
||||||
|
q.SRX = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseInt(rec["stx"]); ok {
|
||||||
|
q.STX = &v
|
||||||
|
}
|
||||||
|
q.SRXString = rec["srx_string"]
|
||||||
|
q.STXString = rec["stx_string"]
|
||||||
|
q.Check = rec["check"]
|
||||||
|
q.Precedence = rec["precedence"]
|
||||||
|
q.ARRLSect = strings.ToUpper(rec["arrl_sect"])
|
||||||
|
|
||||||
|
// Sat / propagation
|
||||||
|
q.PropMode = strings.ToUpper(rec["prop_mode"])
|
||||||
|
q.SatName = strings.ToUpper(rec["sat_name"])
|
||||||
|
q.SatMode = rec["sat_mode"]
|
||||||
|
if v, ok := parseFloat(rec["ant_az"]); ok {
|
||||||
|
q.AntAz = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["ant_el"]); ok {
|
||||||
|
q.AntEl = &v
|
||||||
|
}
|
||||||
|
q.AntPath = strings.ToUpper(rec["ant_path"])
|
||||||
|
|
||||||
|
// My station
|
||||||
|
q.StationCallsign = strings.ToUpper(rec["station_callsign"])
|
||||||
|
q.Operator = strings.ToUpper(rec["operator"])
|
||||||
|
q.MyGrid = strings.ToUpper(rec["my_gridsquare"])
|
||||||
|
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
|
||||||
|
q.MyCountry = rec["my_country"]
|
||||||
|
q.MyState = strings.ToUpper(rec["my_state"])
|
||||||
|
q.MyCounty = rec["my_cnty"]
|
||||||
|
q.MyIOTA = strings.ToUpper(rec["my_iota"])
|
||||||
|
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
|
||||||
|
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
|
||||||
|
if v, ok := parseInt(rec["my_dxcc"]); ok {
|
||||||
|
q.MyDXCC = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseInt(rec["my_cq_zone"]); ok {
|
||||||
|
q.MyCQZone = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseInt(rec["my_itu_zone"]); ok {
|
||||||
|
q.MyITUZone = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["my_lat"]); ok {
|
||||||
|
q.MyLat = &v
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(rec["my_lon"]); ok {
|
||||||
|
q.MyLon = &v
|
||||||
|
}
|
||||||
|
q.MyStreet = rec["my_street"]
|
||||||
|
q.MyCity = rec["my_city"]
|
||||||
|
q.MyPostalCode = rec["my_postal_code"]
|
||||||
|
q.MyRig = rec["my_rig"]
|
||||||
|
q.MyAntenna = rec["my_antenna"]
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
if v, ok := parseFloat(rec["tx_pwr"]); ok {
|
||||||
|
q.TXPower = &v
|
||||||
|
}
|
||||||
|
q.Comment = rec["comment"]
|
||||||
|
q.Notes = rec["notes"]
|
||||||
|
|
||||||
|
// Everything else lands in extras (uppercased ADIF names).
|
||||||
|
var extras map[string]string
|
||||||
|
for k, v := range rec {
|
||||||
|
if _, ok := adifPromoted[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if extras == nil {
|
||||||
|
extras = map[string]string{}
|
||||||
|
}
|
||||||
|
extras[strings.ToUpper(k)] = v
|
||||||
|
}
|
||||||
|
q.Extras = extras
|
||||||
|
|
||||||
|
return q, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDateTime combines ADIF QSO_DATE (YYYYMMDD) with TIME (HHMMSS or HHMM).
|
||||||
|
func parseDateTime(date, timeStr string) time.Time {
|
||||||
|
date = strings.TrimSpace(date)
|
||||||
|
timeStr = strings.TrimSpace(timeStr)
|
||||||
|
if len(date) != 8 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
layout := "20060102"
|
||||||
|
val := date
|
||||||
|
if len(timeStr) == 4 {
|
||||||
|
layout = "200601021504"
|
||||||
|
val = date + timeStr
|
||||||
|
} else if len(timeStr) == 6 {
|
||||||
|
layout = "20060102150405"
|
||||||
|
val = date + timeStr
|
||||||
|
}
|
||||||
|
t, err := time.ParseInLocation(layout, val, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return t.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFreqHz(s string) (int64, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
mhz, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || mhz <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int64(mhz*1_000_000 + 0.5), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string) (int, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) (float64, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package adif handles ADIF import and export (ADI text format).
|
||||||
|
//
|
||||||
|
// ADI tokenisation rules (per ADIF spec):
|
||||||
|
// - Free-form text is allowed up to the first <EOH> (header end).
|
||||||
|
// - After <EOH>, records are sequences of <FIELDNAME:LENGTH[:TYPE]>VALUE
|
||||||
|
// terminated by <EOR>.
|
||||||
|
// - The LENGTH is the byte count of the VALUE that immediately follows
|
||||||
|
// the closing '>' (no separator).
|
||||||
|
// - Tag names are case-insensitive.
|
||||||
|
// - Bytes between fields (whitespace, junk) are ignored.
|
||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record is a single ADIF record. Keys are lowercased field names.
|
||||||
|
type Record map[string]string
|
||||||
|
|
||||||
|
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
|
||||||
|
// Returning a non-nil error from fn stops parsing and is propagated.
|
||||||
|
// The header (text before <EOH>) is silently discarded.
|
||||||
|
func Parse(r io.Reader, fn func(Record) error) error {
|
||||||
|
br := bufio.NewReaderSize(r, 64*1024)
|
||||||
|
|
||||||
|
rec := Record{}
|
||||||
|
headerDone := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Seek next '<'. Bytes before it are either header text or
|
||||||
|
// inter-field whitespace — both discardable.
|
||||||
|
if err := seekByte(br, '<'); err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spec, err := readUntilByte(br, '>')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unterminated tag: %w", err)
|
||||||
|
}
|
||||||
|
name, length := parseSpec(spec)
|
||||||
|
switch name {
|
||||||
|
case "eoh":
|
||||||
|
headerDone = true
|
||||||
|
rec = Record{}
|
||||||
|
continue
|
||||||
|
case "eor":
|
||||||
|
if headerDone && len(rec) > 0 {
|
||||||
|
if err := fn(rec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec = Record{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip value bytes regardless of header state; we only emit
|
||||||
|
// records once we've crossed <EOH>.
|
||||||
|
if length > 0 {
|
||||||
|
val := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(br, val); err != nil {
|
||||||
|
return fmt.Errorf("read field %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if headerDone && name != "" {
|
||||||
|
rec[name] = string(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
|
||||||
|
// name is lowercased; length is 0 for control tags or when missing.
|
||||||
|
func parseSpec(spec string) (name string, length int) {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(spec), ":", 3)
|
||||||
|
name = strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && n > 0 {
|
||||||
|
length = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func seekByte(br *bufio.Reader, target byte) error {
|
||||||
|
for {
|
||||||
|
b, err := br.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b == target {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUntilByte(br *bufio.Reader, target byte) (string, error) {
|
||||||
|
var sb strings.Builder
|
||||||
|
for {
|
||||||
|
b, err := br.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return sb.String(), err
|
||||||
|
}
|
||||||
|
if b == target {
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseSimple(t *testing.T) {
|
||||||
|
src := `Header text here
|
||||||
|
Generated by HamLog
|
||||||
|
<EOH>
|
||||||
|
<CALL:5>F4XYZ<BAND:3>20m<MODE:3>SSB<QSO_DATE:8>20240101<TIME_ON:6>123456<EOR>
|
||||||
|
<call:4>K1AB<band:3>40m<mode:2>CW<qso_date:8>20240102<time_on:4>0930<eor>
|
||||||
|
`
|
||||||
|
var got []Record
|
||||||
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("want 2 records, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["call"] != "F4XYZ" || got[0]["band"] != "20m" || got[0]["mode"] != "SSB" {
|
||||||
|
t.Errorf("record 0 mismatch: %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1]["call"] != "K1AB" || got[1]["time_on"] != "0930" {
|
||||||
|
t.Errorf("record 1 mismatch: %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValueWithAngleBracket(t *testing.T) {
|
||||||
|
// Length-prefixed value can contain '<' and '>' bytes.
|
||||||
|
src := `<EOH><CALL:5>F4XYZ<COMMENT:7>a<b>c<d<EOR>`
|
||||||
|
var got []Record
|
||||||
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
|
got = append(got, r)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want 1, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["comment"] != "a<b>c<d" {
|
||||||
|
t.Errorf("comment mismatch: %q", got[0]["comment"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNoHeader(t *testing.T) {
|
||||||
|
// Some loggers omit the header entirely — records before <EOH> are
|
||||||
|
// discarded by design. Verify nothing is emitted in that case.
|
||||||
|
src := `<CALL:5>F4XYZ<EOR>`
|
||||||
|
var got int
|
||||||
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
|
got++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("expected 0 records without <EOH>, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTypedField(t *testing.T) {
|
||||||
|
// <FIELD:LEN:TYPE> form (e.g. <FREQ:6:N>).
|
||||||
|
src := `<EOH><CALL:5>F4XYZ<FREQ:6:N>14.250<EOR>`
|
||||||
|
var got Record
|
||||||
|
err := Parse(strings.NewReader(src), func(r Record) error {
|
||||||
|
got = r
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got["freq"] != "14.250" {
|
||||||
|
t.Errorf("freq mismatch: %q", got["freq"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package antenna drives antennas (Ultrabeam in particular).
|
||||||
|
// TODO: implementation.
|
||||||
|
package antenna
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Package api exposes a small local HTTP REST server that lets third-party
|
||||||
|
// tools push a callsign (or a full QSO) into the logbook.
|
||||||
|
// TODO: implementation.
|
||||||
|
package api
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package award implements the awards engine (DXCC, WAS, WAZ, IOTA, SOTA, …)
|
||||||
|
// and a rule system letting the user define custom awards
|
||||||
|
// (matching on band/mode/country/grid/etc.).
|
||||||
|
// TODO: implementation.
|
||||||
|
package award
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// Package cat drives the transceiver via swappable backends (OmniRig, Flex…)
|
||||||
|
// and pushes state changes to the UI through an injected emitter callback.
|
||||||
|
//
|
||||||
|
// The poll loop runs on an OS-thread-locked goroutine so COM-based backends
|
||||||
|
// (OmniRig) work correctly — COM is thread-affine on Windows and must be
|
||||||
|
// initialised, used and uninitialised from the same OS thread.
|
||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend abstracts a specific transceiver-control library. All methods run
|
||||||
|
// on the dedicated CAT goroutine spawned by Manager — implementations can
|
||||||
|
// assume single-threaded access and can safely manage thread-bound resources
|
||||||
|
// (e.g. COM objects in OmniRig).
|
||||||
|
type Backend interface {
|
||||||
|
Name() string // "omnirig" | "flex" | …
|
||||||
|
Connect() error
|
||||||
|
Disconnect()
|
||||||
|
ReadState() (RigState, error)
|
||||||
|
SetFrequency(hz int64) error
|
||||||
|
// SetMode receives an ADIF mode string (SSB, CW, FT8, RTTY, AM, FM…).
|
||||||
|
// Implementations decide USB vs LSB (typically by current freq) and
|
||||||
|
// generic vs specific digital modes (most rigs just have DATA).
|
||||||
|
SetMode(mode string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigState is the snapshot exchanged with the frontend.
|
||||||
|
//
|
||||||
|
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
||||||
|
// rig is in split, FreqHz is the inactive VFO (where the operator transmits)
|
||||||
|
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||||
|
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||||
|
type RigState struct {
|
||||||
|
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||||
|
Connected bool `json:"connected"` // backend says rig is online
|
||||||
|
Backend string `json:"backend,omitempty"` // active backend name
|
||||||
|
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||||
|
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||||
|
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||||
|
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||||
|
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||||
|
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||||
|
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||||
|
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||||
|
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager owns the active backend and runs the polling loop.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
state RigState
|
||||||
|
emit func(RigState)
|
||||||
|
backend Backend
|
||||||
|
|
||||||
|
// Set when running. nil when stopped.
|
||||||
|
stopCh chan struct{}
|
||||||
|
doneCh chan struct{}
|
||||||
|
cmdCh chan func() // marshall arbitrary work onto the CAT goroutine
|
||||||
|
|
||||||
|
pollEvery time.Duration
|
||||||
|
cmdDelay time.Duration // pause after each command (some rigs need it)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(emit func(RigState)) *Manager {
|
||||||
|
return &Manager{emit: emit, pollEvery: 250 * time.Millisecond}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPollInterval changes the polling cadence. Caps at 50ms…2s to avoid
|
||||||
|
// either hammering the rig or feeling laggy.
|
||||||
|
func (m *Manager) SetPollInterval(d time.Duration) {
|
||||||
|
if d < 50*time.Millisecond {
|
||||||
|
d = 50 * time.Millisecond
|
||||||
|
}
|
||||||
|
if d > 2*time.Second {
|
||||||
|
d = 2 * time.Second
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.pollEvery = d
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommandDelay sets a pause inserted after each CAT command. Some older
|
||||||
|
// Kenwood/Yaesu rigs drop bytes if commands arrive too fast back to back.
|
||||||
|
// Capped at 0…500ms — beyond that, fix your rig.
|
||||||
|
func (m *Manager) SetCommandDelay(d time.Duration) {
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
if d > 500*time.Millisecond {
|
||||||
|
d = 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.cmdDelay = d
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns a copy of the latest known state.
|
||||||
|
func (m *Manager) State() RigState {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start spins up the CAT goroutine with the given backend. If a backend is
|
||||||
|
// already running it is stopped first. Errors during Connect are surfaced as
|
||||||
|
// state.Error rather than returned, so the UI can keep retrying via the
|
||||||
|
// poll loop on next reconnect attempt.
|
||||||
|
func (m *Manager) Start(b Backend) {
|
||||||
|
m.Stop()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.stopCh = make(chan struct{})
|
||||||
|
m.doneCh = make(chan struct{})
|
||||||
|
m.cmdCh = make(chan func(), 4)
|
||||||
|
m.backend = b
|
||||||
|
stop := m.stopCh
|
||||||
|
done := m.doneCh
|
||||||
|
cmds := m.cmdCh
|
||||||
|
poll := m.pollEvery
|
||||||
|
m.state = RigState{Enabled: true, Backend: b.Name()}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
|
|
||||||
|
go m.run(b, stop, done, cmds, poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.stopCh
|
||||||
|
done := m.doneCh
|
||||||
|
m.stopCh = nil
|
||||||
|
m.doneCh = nil
|
||||||
|
m.cmdCh = nil
|
||||||
|
m.backend = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state = RigState{Enabled: false}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.emitState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||||
|
func (m *Manager) SetFrequency(hz int64) error {
|
||||||
|
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMode dispatches a SetMode call to the CAT goroutine.
|
||||||
|
func (m *Manager) SetMode(mode string) error {
|
||||||
|
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
cmds := m.cmdCh
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if cmds == nil || b == nil {
|
||||||
|
return fmt.Errorf("cat not running")
|
||||||
|
}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
select {
|
||||||
|
case cmds <- func() { errCh <- fn(b) }:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
return fmt.Errorf("cat busy")
|
||||||
|
}
|
||||||
|
return <-errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the CAT goroutine. Owns the backend lifecycle.
|
||||||
|
func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pollEvery time.Duration) {
|
||||||
|
// Lock to a single OS thread — required for COM. Cheap for non-COM backends.
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
if err := b.Connect(); err != nil {
|
||||||
|
m.update(RigState{
|
||||||
|
Enabled: true, Backend: b.Name(), Connected: false,
|
||||||
|
Error: err.Error(), UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
// Stay idle until Stop is called — let the user fix config and re-Start.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case fn := <-cmds:
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer b.Disconnect()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(pollEvery)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case fn := <-cmds:
|
||||||
|
fn()
|
||||||
|
m.applyCommandDelay()
|
||||||
|
case <-ticker.C:
|
||||||
|
ns, err := b.ReadState()
|
||||||
|
if err != nil {
|
||||||
|
m.update(RigState{
|
||||||
|
Enabled: true, Backend: b.Name(), Connected: false,
|
||||||
|
Error: err.Error(), UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ns.Enabled = true
|
||||||
|
ns.Backend = b.Name()
|
||||||
|
ns.UpdatedAt = time.Now()
|
||||||
|
if ns.FreqHz != 0 && ns.Band == "" {
|
||||||
|
ns.Band = BandFromHz(ns.FreqHz)
|
||||||
|
}
|
||||||
|
m.update(ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) applyCommandDelay() {
|
||||||
|
m.mu.RLock()
|
||||||
|
d := m.cmdDelay
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if d > 0 {
|
||||||
|
time.Sleep(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update stores the new state and emits an event ONLY if something changed
|
||||||
|
// that the UI cares about — avoids flooding the event bus 4x per second.
|
||||||
|
func (m *Manager) update(ns RigState) {
|
||||||
|
m.mu.Lock()
|
||||||
|
changed := !stateUserEqual(m.state, ns)
|
||||||
|
m.state = ns
|
||||||
|
m.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
m.emitState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) emitState() {
|
||||||
|
if m.emit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.emit(m.State())
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateUserEqual(a, b RigState) bool {
|
||||||
|
return a.Enabled == b.Enabled &&
|
||||||
|
a.Connected == b.Connected &&
|
||||||
|
a.Backend == b.Backend &&
|
||||||
|
a.RigNum == b.RigNum &&
|
||||||
|
a.Rig == b.Rig &&
|
||||||
|
a.FreqHz == b.FreqHz &&
|
||||||
|
a.RxFreqHz == b.RxFreqHz &&
|
||||||
|
a.Split == b.Split &&
|
||||||
|
a.Mode == b.Mode &&
|
||||||
|
a.Vfo == b.Vfo &&
|
||||||
|
a.Band == b.Band &&
|
||||||
|
a.Error == b.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandFromHz returns the ADIF band tag covering the given frequency, or "".
|
||||||
|
// Ranges follow IARU/ITU plans. 60m is treated as a single block for
|
||||||
|
// simplicity — channelised access varies by region.
|
||||||
|
func BandFromHz(hz int64) string {
|
||||||
|
mhz := float64(hz) / 1_000_000
|
||||||
|
switch {
|
||||||
|
case mhz >= 1.8 && mhz <= 2.0:
|
||||||
|
return "160m"
|
||||||
|
case mhz >= 3.5 && mhz <= 4.0:
|
||||||
|
return "80m"
|
||||||
|
case mhz >= 5.3 && mhz <= 5.5:
|
||||||
|
return "60m"
|
||||||
|
case mhz >= 7.0 && mhz <= 7.3:
|
||||||
|
return "40m"
|
||||||
|
case mhz >= 10.1 && mhz <= 10.15:
|
||||||
|
return "30m"
|
||||||
|
case mhz >= 14.0 && mhz <= 14.35:
|
||||||
|
return "20m"
|
||||||
|
case mhz >= 18.068 && mhz <= 18.168:
|
||||||
|
return "17m"
|
||||||
|
case mhz >= 21.0 && mhz <= 21.45:
|
||||||
|
return "15m"
|
||||||
|
case mhz >= 24.89 && mhz <= 24.99:
|
||||||
|
return "12m"
|
||||||
|
case mhz >= 28.0 && mhz <= 29.7:
|
||||||
|
return "10m"
|
||||||
|
case mhz >= 50.0 && mhz <= 54.0:
|
||||||
|
return "6m"
|
||||||
|
case mhz >= 70.0 && mhz <= 70.5:
|
||||||
|
return "4m"
|
||||||
|
case mhz >= 144.0 && mhz <= 148.0:
|
||||||
|
return "2m"
|
||||||
|
case mhz >= 222.0 && mhz <= 225.0:
|
||||||
|
return "1.25m"
|
||||||
|
case mhz >= 420.0 && mhz <= 450.0:
|
||||||
|
return "70cm"
|
||||||
|
case mhz >= 902.0 && mhz <= 928.0:
|
||||||
|
return "33cm"
|
||||||
|
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||||
|
return "23cm"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can
|
||||||
|
// diagnose mode/freq mismatches without rebuilding with -windowsconsole.
|
||||||
|
//
|
||||||
|
// Initialised lazily on first use. Falls back to the standard library
|
||||||
|
// default logger (stderr, usually invisible in a Wails GUI build) if the
|
||||||
|
// log file can't be opened.
|
||||||
|
var debugLog = openDebugLog()
|
||||||
|
|
||||||
|
func openDebugLog() *log.Logger {
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return log.Default()
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, "HamLog")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return log.Default()
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(filepath.Join(dir, "cat.log"),
|
||||||
|
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return log.Default()
|
||||||
|
}
|
||||||
|
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugLogPath returns the path the cat.log file would be opened at, for
|
||||||
|
// surfacing in the UI / docs.
|
||||||
|
func DebugLogPath() string {
|
||||||
|
base, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "HamLog", "cat.log")
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-ole/go-ole"
|
||||||
|
"github.com/go-ole/go-ole/oleutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OmniRig talks to the user's installed OmniRig server over COM.
|
||||||
|
//
|
||||||
|
// All methods MUST be called from the same OS thread (the one Manager.run
|
||||||
|
// locks). COM is thread-affine on Windows — calling these from random
|
||||||
|
// goroutines will return E_FAIL or crash.
|
||||||
|
//
|
||||||
|
// The user must install OmniRig separately and configure their rig (COM port,
|
||||||
|
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
|
||||||
|
type OmniRig struct {
|
||||||
|
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||||
|
|
||||||
|
omnirig *ole.IDispatch
|
||||||
|
rig *ole.IDispatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
|
func NewOmniRig(rigNum int) *OmniRig {
|
||||||
|
if rigNum < 1 || rigNum > 2 {
|
||||||
|
rigNum = 1
|
||||||
|
}
|
||||||
|
return &OmniRig{RigNum: rigNum}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OmniRig) Name() string { return "omnirig" }
|
||||||
|
|
||||||
|
func (o *OmniRig) Connect() error {
|
||||||
|
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
|
||||||
|
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
||||||
|
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
|
||||||
|
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
|
||||||
|
return fmt.Errorf("CoInitializeEx: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
|
||||||
|
}
|
||||||
|
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
|
||||||
|
unk.Release()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query interface: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
|
||||||
|
if err != nil {
|
||||||
|
omnirig.Release()
|
||||||
|
return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
|
||||||
|
}
|
||||||
|
o.omnirig = omnirig
|
||||||
|
o.rig = rigVar.ToIDispatch()
|
||||||
|
|
||||||
|
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
|
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OmniRig) Disconnect() {
|
||||||
|
if o.rig != nil {
|
||||||
|
o.rig.Release()
|
||||||
|
o.rig = nil
|
||||||
|
}
|
||||||
|
if o.omnirig != nil {
|
||||||
|
o.omnirig.Release()
|
||||||
|
o.omnirig = nil
|
||||||
|
}
|
||||||
|
ole.CoUninitialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OmniRig) ReadState() (RigState, error) {
|
||||||
|
if o.rig == nil {
|
||||||
|
return RigState{}, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
var s RigState
|
||||||
|
s.Backend = o.Name()
|
||||||
|
s.RigNum = o.RigNum
|
||||||
|
|
||||||
|
// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
|
||||||
|
// 3 = NOTRESPONDING, 4 = ONLINE.
|
||||||
|
if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
|
||||||
|
s.Connected = statusVar.Val == 4
|
||||||
|
}
|
||||||
|
|
||||||
|
if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||||
|
s.Rig = rigTypeVar.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.Connected {
|
||||||
|
// Status string from OmniRig is informative for the user.
|
||||||
|
if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
|
||||||
|
s.Error = statusStrVar.ToString()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||||
|
s.Mode = omniRigMode(modeVar.Val)
|
||||||
|
}
|
||||||
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
|
s.Vfo = omniRigVfo(vfoVar.Val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read both VFO frequencies separately so we can expose split TX/RX.
|
||||||
|
// Fall back to generic Freq if the rig only exposes the merged property.
|
||||||
|
freqA, freqB := int64(0), int64(0)
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||||
|
freqA = v.Val
|
||||||
|
}
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||||
|
freqB = v.Val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split detection: trust the explicit Split property when it's set,
|
||||||
|
// BUT only call it a real split if both VFO frequencies are non-zero
|
||||||
|
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
|
||||||
|
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
|
||||||
|
// slice model doesn't map to VFO A/B — that would yield a useless
|
||||||
|
// permanent SPLIT badge.
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
|
||||||
|
s.Split = true
|
||||||
|
}
|
||||||
|
if s.Split && (freqB == 0 || freqA == freqB) {
|
||||||
|
s.Split = false
|
||||||
|
s.RxFreqHz = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
||||||
|
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
|
||||||
|
switch s.Vfo {
|
||||||
|
case "AB":
|
||||||
|
s.FreqHz = freqB // TX
|
||||||
|
s.RxFreqHz = freqA // RX
|
||||||
|
case "BA":
|
||||||
|
s.FreqHz = freqA // TX
|
||||||
|
s.RxFreqHz = freqB // RX
|
||||||
|
case "B", "BB":
|
||||||
|
s.FreqHz = freqB
|
||||||
|
default: // "A", "AA", "" — single VFO on A or unknown
|
||||||
|
s.FreqHz = freqA
|
||||||
|
}
|
||||||
|
if s.FreqHz == 0 {
|
||||||
|
// Last resort — some rigs only update generic Freq.
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
s.FreqHz = v.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OmniRig) SetFrequency(hz int64) error {
|
||||||
|
if o.rig == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
|
||||||
|
if hz < 0 || hz > 0x7fffffff {
|
||||||
|
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||||
|
}
|
||||||
|
hz32 := int32(hz)
|
||||||
|
|
||||||
|
// Pick the right OmniRig property. Many rig .ini files only define a
|
||||||
|
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
|
||||||
|
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
|
||||||
|
// the active VFO's specific property when we know it; fall back to Freq.
|
||||||
|
prop := "FreqA"
|
||||||
|
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||||
|
switch omniRigVfo(vfoVar.Val) {
|
||||||
|
case "B", "BB", "BA":
|
||||||
|
prop = "FreqB"
|
||||||
|
case "A", "AA", "AB":
|
||||||
|
prop = "FreqA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
|
||||||
|
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
|
||||||
|
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
|
||||||
|
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back the active VFO freq after a short delay so the log shows
|
||||||
|
// whether the rig actually moved. Useful when the .ini accepts the write
|
||||||
|
// silently but the rig doesn't honour it (wrong WRITE command etc.).
|
||||||
|
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
|
||||||
|
// For SSB, the USB/LSB side is chosen from the rig's current frequency
|
||||||
|
// following worldwide convention (LSB below 14 MHz, USB above).
|
||||||
|
//
|
||||||
|
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
|
||||||
|
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
|
||||||
|
// reject silently or misinterpret — passing the wrong bit. Always cast to
|
||||||
|
// int32 explicitly.
|
||||||
|
//
|
||||||
|
// Logs each call to stdout so the user can cross-check what HamLog sent
|
||||||
|
// against OmniRig's Monitor window (right-click systray → Monitor) to find
|
||||||
|
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
|
||||||
|
// wrong CAT command for a mode, etc.).
|
||||||
|
func (o *OmniRig) SetMode(mode string) error {
|
||||||
|
if o.rig == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
bit int64
|
||||||
|
bitName string
|
||||||
|
)
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
bit, bitName = pmCWU, "PM_CW_U"
|
||||||
|
case "SSB":
|
||||||
|
// Read current freq to decide USB vs LSB.
|
||||||
|
var freq int64
|
||||||
|
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||||
|
freq = freqVar.Val
|
||||||
|
}
|
||||||
|
if freq > 0 && freq < 10_000_000 {
|
||||||
|
bit, bitName = pmSSBL, "PM_SSB_L"
|
||||||
|
} else {
|
||||||
|
bit, bitName = pmSSBU, "PM_SSB_U"
|
||||||
|
}
|
||||||
|
case "AM":
|
||||||
|
bit, bitName = pmAM, "PM_AM"
|
||||||
|
case "FM":
|
||||||
|
bit, bitName = pmFM, "PM_FM"
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
// OmniRig has no specific RTTY/FSK mode — falls back to generic
|
||||||
|
// digital USB. Many rigs need RTTY selected manually on the panel.
|
||||||
|
bit, bitName = pmDIGU, "PM_DIG_U"
|
||||||
|
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
|
||||||
|
bit, bitName = pmDIGU, "PM_DIG_U"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("OmniRig: unsupported mode %q", mode)
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
|
||||||
|
_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("OmniRig.SetMode error: %v", err)
|
||||||
|
return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back what OmniRig now thinks the rig is on (best-effort —
|
||||||
|
// OmniRig is async so this may still be the old value for one poll).
|
||||||
|
if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||||
|
debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
|
||||||
|
mv.Val, mv.Val, omniRigMode(mv.Val))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OmniRig enum decoders =====
|
||||||
|
|
||||||
|
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
|
||||||
|
//
|
||||||
|
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
|
||||||
|
// referencing other people's writeups online, several have these one bit
|
||||||
|
// too low which causes every mode to map to the slot below it (AM → DIG_L,
|
||||||
|
// FT8 → SSB_L, etc.).
|
||||||
|
const (
|
||||||
|
pmCWU int64 = 1 << 23 // 0x00800000
|
||||||
|
pmCWL int64 = 1 << 24 // 0x01000000
|
||||||
|
pmSSBU int64 = 1 << 25 // 0x02000000
|
||||||
|
pmSSBL int64 = 1 << 26 // 0x04000000
|
||||||
|
pmDIGU int64 = 1 << 27 // 0x08000000
|
||||||
|
pmDIGL int64 = 1 << 28 // 0x10000000
|
||||||
|
pmAM int64 = 1 << 29 // 0x20000000
|
||||||
|
pmFM int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
|
||||||
|
// OmniRig only reports rough categories; specific digital modes
|
||||||
|
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
|
||||||
|
// can keep / override the mode they already had in the entry form.
|
||||||
|
func omniRigMode(m int64) string {
|
||||||
|
switch {
|
||||||
|
case m&(pmCWU|pmCWL) != 0:
|
||||||
|
return "CW"
|
||||||
|
case m&(pmSSBU|pmSSBL) != 0:
|
||||||
|
return "SSB"
|
||||||
|
case m&(pmDIGU|pmDIGL) != 0:
|
||||||
|
return "DATA"
|
||||||
|
case m&pmAM != 0:
|
||||||
|
return "AM"
|
||||||
|
case m&pmFM != 0:
|
||||||
|
return "FM"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func omniRigVfo(v int64) string {
|
||||||
|
switch {
|
||||||
|
case v&1024 != 0:
|
||||||
|
return "A"
|
||||||
|
case v&2048 != 0:
|
||||||
|
return "B"
|
||||||
|
case v&64 != 0:
|
||||||
|
return "AA"
|
||||||
|
case v&128 != 0:
|
||||||
|
return "AB"
|
||||||
|
case v&256 != 0:
|
||||||
|
return "BA"
|
||||||
|
case v&512 != 0:
|
||||||
|
return "BB"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Package cluster provides a DX cluster client (telnet) with filters
|
||||||
|
// (band, mode, callsign, continent, ITU/CQ, …).
|
||||||
|
// TODO: implementation.
|
||||||
|
package cluster
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Package db handles the SQLite connection and migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||||
|
// enables performance PRAGMAs, and applies embedded migrations.
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", path)
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if err := conn.Ping(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if err := migrate(conn); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||||
|
// skipping those already applied. Intentionally minimal in-house system
|
||||||
|
// (no external dependency).
|
||||||
|
func migrate(conn *sql.DB) error {
|
||||||
|
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
var dummy string
|
||||||
|
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
||||||
|
if err == nil {
|
||||||
|
continue // already applied
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
tx, err := conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- HamLog initial schema
|
||||||
|
-- QSO table: core of the logbook. Field names stay close to ADIF.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qso (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
callsign TEXT NOT NULL,
|
||||||
|
qso_date TEXT NOT NULL, -- ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ
|
||||||
|
band TEXT NOT NULL, -- e.g. 20m, 40m, 2m
|
||||||
|
mode TEXT NOT NULL, -- e.g. SSB, CW, FT8
|
||||||
|
freq_hz INTEGER, -- frequency in Hz (integer, avoids floats)
|
||||||
|
rst_sent TEXT,
|
||||||
|
rst_rcvd TEXT,
|
||||||
|
name TEXT,
|
||||||
|
qth TEXT,
|
||||||
|
grid TEXT,
|
||||||
|
country TEXT,
|
||||||
|
dxcc INTEGER,
|
||||||
|
cont TEXT,
|
||||||
|
cqz INTEGER,
|
||||||
|
ituz INTEGER,
|
||||||
|
iota TEXT,
|
||||||
|
sota_ref TEXT,
|
||||||
|
pota_ref TEXT,
|
||||||
|
-- Operator context (multi-callsign / multi-location)
|
||||||
|
station_callsign TEXT,
|
||||||
|
operator TEXT,
|
||||||
|
my_grid TEXT,
|
||||||
|
my_country TEXT,
|
||||||
|
my_sota_ref TEXT,
|
||||||
|
my_pota_ref TEXT,
|
||||||
|
-- Misc
|
||||||
|
tx_pwr REAL,
|
||||||
|
comment TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
qsl_sent TEXT DEFAULT 'N',
|
||||||
|
qsl_rcvd TEXT DEFAULT 'N',
|
||||||
|
lotw_sent TEXT DEFAULT 'N',
|
||||||
|
lotw_rcvd TEXT DEFAULT 'N',
|
||||||
|
eqsl_sent TEXT DEFAULT 'N',
|
||||||
|
eqsl_rcvd TEXT DEFAULT 'N',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_callsign ON qso(callsign);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_date ON qso(qso_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_band_mode ON qso(band, mode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_grid ON qso(grid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_station ON qso(station_callsign);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Key/value app settings (lookup credentials, preferences, …)
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Local cache of QRZ/HamQTH lookups to avoid re-querying for the same call.
|
||||||
|
CREATE TABLE IF NOT EXISTS callsign_cache (
|
||||||
|
callsign TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
qth TEXT,
|
||||||
|
country TEXT,
|
||||||
|
grid TEXT,
|
||||||
|
dxcc INTEGER,
|
||||||
|
cqz INTEGER,
|
||||||
|
ituz INTEGER,
|
||||||
|
cont TEXT,
|
||||||
|
source TEXT NOT NULL, -- 'qrz' | 'hamqth'
|
||||||
|
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
-- Expand the QSO table to cover the rest of the common ADIF fields.
|
||||||
|
-- SQLite ALTER TABLE ADD COLUMN is metadata-only, so this is fast even on
|
||||||
|
-- large logbooks. Anything not promoted to a column lives in extras_json.
|
||||||
|
|
||||||
|
-- --- Times / frequencies / mode ---
|
||||||
|
ALTER TABLE qso ADD COLUMN qso_date_off TEXT; -- ISO UTC end datetime
|
||||||
|
ALTER TABLE qso ADD COLUMN freq_rx_hz INTEGER; -- RX frequency for split operation
|
||||||
|
ALTER TABLE qso ADD COLUMN band_rx TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN submode TEXT; -- USB, LSB, USB-DATA, ...
|
||||||
|
|
||||||
|
-- --- Contacted station extras ---
|
||||||
|
ALTER TABLE qso ADD COLUMN state TEXT; -- US state, JA prefecture, etc.
|
||||||
|
ALTER TABLE qso ADD COLUMN cnty TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN address TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN email TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN web TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN age INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN lat REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN lon REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN gridsquare_ext TEXT; -- 8/10-char extension
|
||||||
|
ALTER TABLE qso ADD COLUMN vucc_grids TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN rig TEXT; -- contacted station's rig
|
||||||
|
ALTER TABLE qso ADD COLUMN ant TEXT; -- contacted station's antenna
|
||||||
|
|
||||||
|
-- --- QSL bureau / direct / LoTW / eQSL / Clublog / HRDLog ---
|
||||||
|
ALTER TABLE qso ADD COLUMN qsl_via TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN qsl_msg TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN qslmsg_rcvd TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN qsl_sent_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN qsl_rcvd_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN lotw_sent_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN lotw_rcvd_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN eqsl_sent_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN eqsl_rcvd_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN clublog_qso_upload_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN clublog_qso_upload_status TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_date TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_status TEXT;
|
||||||
|
|
||||||
|
-- --- Contest ---
|
||||||
|
ALTER TABLE qso ADD COLUMN contest_id TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN srx INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN stx INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN srx_string TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN stx_string TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN check_field TEXT; -- ADIF CHECK (reserved word in SQL)
|
||||||
|
ALTER TABLE qso ADD COLUMN precedence TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN arrl_sect TEXT;
|
||||||
|
|
||||||
|
-- --- Satellite / propagation ---
|
||||||
|
ALTER TABLE qso ADD COLUMN prop_mode TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN sat_name TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN sat_mode TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN ant_az REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN ant_el REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN ant_path TEXT;
|
||||||
|
|
||||||
|
-- --- My station extras (per-QSO overrides of the active profile) ---
|
||||||
|
ALTER TABLE qso ADD COLUMN my_state TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_cnty TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_iota TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_dxcc INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_cq_zone INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_itu_zone INTEGER;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_lat REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_lon REAL;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_street TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_city TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_postal_code TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_rig TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_antenna TEXT;
|
||||||
|
ALTER TABLE qso ADD COLUMN my_gridsquare_ext TEXT;
|
||||||
|
|
||||||
|
-- --- Catch-all for ADIF fields we don't promote to columns ---
|
||||||
|
-- JSON object: { "FIELD_NAME": "value", ... } (keys uppercase as in ADIF).
|
||||||
|
ALTER TABLE qso ADD COLUMN extras_json TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_state ON qso(state);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_contest_id ON qso(contest_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_sat_name ON qso(sat_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qso_prop_mode ON qso(prop_mode);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Extra columns captured from QRZ/HamQTH lookups, mapped onto F2 Info fields.
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN address TEXT;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN state TEXT;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN cnty TEXT;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN lat REAL;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN lon REAL;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN email TEXT;
|
||||||
|
ALTER TABLE callsign_cache ADD COLUMN qsl_via TEXT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- station_profiles: one row per operating configuration (home, portable,
|
||||||
|
-- SOTA, /MM, contest…). The user picks one as active; every QSO stamps
|
||||||
|
-- the active profile's MY_* fields. Place reserved for per-profile creds
|
||||||
|
-- (LoTW, Clublog, QRZ.com) in a later migration once those exports land.
|
||||||
|
CREATE TABLE station_profiles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL, -- "Home", "Portable", "SOTA"…
|
||||||
|
callsign TEXT NOT NULL DEFAULT '',
|
||||||
|
operator TEXT NOT NULL DEFAULT '',
|
||||||
|
my_grid TEXT NOT NULL DEFAULT '',
|
||||||
|
my_country TEXT NOT NULL DEFAULT '',
|
||||||
|
my_state TEXT NOT NULL DEFAULT '',
|
||||||
|
my_cnty TEXT NOT NULL DEFAULT '',
|
||||||
|
my_street TEXT NOT NULL DEFAULT '',
|
||||||
|
my_city TEXT NOT NULL DEFAULT '',
|
||||||
|
my_postal_code TEXT NOT NULL DEFAULT '',
|
||||||
|
my_sota_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
my_pota_ref TEXT NOT NULL DEFAULT '',
|
||||||
|
my_rig TEXT NOT NULL DEFAULT '',
|
||||||
|
my_antenna TEXT NOT NULL DEFAULT '',
|
||||||
|
tx_pwr REAL, -- nullable: not always known
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 0, -- 1 for the currently-selected profile
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only one profile can be active at a time. Enforced lazily — the Go side
|
||||||
|
-- clears all then sets one before each switch.
|
||||||
|
CREATE INDEX idx_station_profiles_active ON station_profiles(is_active);
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU
|
||||||
|
// zones, continent) by longest-prefix-matching against cty.dat — the
|
||||||
|
// canonical prefix database maintained by AD1C at country-files.com,
|
||||||
|
// the same file that every contest / logger consumes.
|
||||||
|
//
|
||||||
|
// The parser is line-oriented and tolerant: it handles cty.dat's
|
||||||
|
// per-prefix overrides ((CQ), [ITU], <lat/lon>, {Cont}) and the
|
||||||
|
// "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM,
|
||||||
|
// /5, …) are stripped before matching.
|
||||||
|
package dxcc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity is one DXCC entity entry from cty.dat.
|
||||||
|
type Entity struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Continent string `json:"continent"`
|
||||||
|
CQZone int `json:"cqz"`
|
||||||
|
ITUZone int `json:"ituz"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
TZOffset float64 `json:"tz_offset"`
|
||||||
|
Primary string `json:"primary_prefix"` // canonical short prefix (F, DL, K, …)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match is the resolved DXCC info for a callsign. Per-prefix overrides
|
||||||
|
// from cty.dat are baked in; the Entity pointer is the unmodified parent.
|
||||||
|
type Match struct {
|
||||||
|
Entity *Entity `json:"entity"`
|
||||||
|
Prefix string `json:"matched_prefix"`
|
||||||
|
CQZone int `json:"cqz"`
|
||||||
|
ITUZone int `json:"ituz"`
|
||||||
|
Continent string `json:"continent"`
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type prefixEntry struct {
|
||||||
|
prefix string
|
||||||
|
entity *Entity
|
||||||
|
cqOverride int
|
||||||
|
ituOverride int
|
||||||
|
contOverride string
|
||||||
|
latOverride float64
|
||||||
|
lonOverride float64
|
||||||
|
hasLatLon bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB is a parsed cty.dat ready for lookups.
|
||||||
|
type DB struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entities []*Entity
|
||||||
|
exact map[string]prefixEntry // "=CALLSIGN" entries
|
||||||
|
byPrefix []prefixEntry // sorted longest first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parses a cty.dat stream. Safe to call once at startup.
|
||||||
|
func Load(r io.Reader) (*DB, error) {
|
||||||
|
db := &DB{exact: make(map[string]prefixEntry)}
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
// cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine
|
||||||
|
// but we bump it to be safe.
|
||||||
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
var current *Entity
|
||||||
|
var buf strings.Builder
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimRight(sc.Text(), "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Entity header lines start at column 0; continuation lines are
|
||||||
|
// indented (cty.dat uses 4 spaces).
|
||||||
|
if line[0] != ' ' && line[0] != '\t' {
|
||||||
|
if e := parseEntityHeader(line); e != nil {
|
||||||
|
db.entities = append(db.entities, e)
|
||||||
|
current = e
|
||||||
|
buf.Reset()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteString(strings.TrimSpace(line))
|
||||||
|
// An entity's prefix list ends with ';' — possibly on a later line.
|
||||||
|
if strings.HasSuffix(strings.TrimSpace(line), ";") {
|
||||||
|
text := strings.TrimSuffix(buf.String(), ";")
|
||||||
|
for _, raw := range strings.Split(text, ",") {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, exact := parsePrefix(raw, current)
|
||||||
|
if exact {
|
||||||
|
db.exact[entry.prefix] = entry
|
||||||
|
} else {
|
||||||
|
db.byPrefix = append(db.byPrefix, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Longest prefix first so HasPrefix wins on the most specific match.
|
||||||
|
sort.Slice(db.byPrefix, func(i, j int) bool {
|
||||||
|
return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix)
|
||||||
|
})
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entities returns the parsed entity list (read-only).
|
||||||
|
func (db *DB) Entities() []*Entity {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
return db.entities
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup resolves a callsign to its DXCC match using longest-prefix-match.
|
||||||
|
// Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes
|
||||||
|
// (DL/F4NIE → uses DL). Returns false if no prefix matches.
|
||||||
|
func (db *DB) Lookup(callsign string) (Match, bool) {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
call := normalizeCallsign(callsign)
|
||||||
|
if call == "" {
|
||||||
|
return Match{}, false
|
||||||
|
}
|
||||||
|
if e, ok := db.exact[call]; ok {
|
||||||
|
return materialize(e), true
|
||||||
|
}
|
||||||
|
for _, p := range db.byPrefix {
|
||||||
|
if strings.HasPrefix(call, p.prefix) {
|
||||||
|
return materialize(p), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Match{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func materialize(e prefixEntry) Match {
|
||||||
|
m := Match{
|
||||||
|
Entity: e.entity,
|
||||||
|
Prefix: e.prefix,
|
||||||
|
CQZone: e.entity.CQZone,
|
||||||
|
ITUZone: e.entity.ITUZone,
|
||||||
|
Continent: e.entity.Continent,
|
||||||
|
Lat: e.entity.Lat,
|
||||||
|
Lon: e.entity.Lon,
|
||||||
|
}
|
||||||
|
if e.cqOverride != 0 {
|
||||||
|
m.CQZone = e.cqOverride
|
||||||
|
}
|
||||||
|
if e.ituOverride != 0 {
|
||||||
|
m.ITUZone = e.ituOverride
|
||||||
|
}
|
||||||
|
if e.contOverride != "" {
|
||||||
|
m.Continent = e.contOverride
|
||||||
|
}
|
||||||
|
if e.hasLatLon {
|
||||||
|
m.Lat = e.latOverride
|
||||||
|
m.Lon = e.lonOverride
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseEntityHeader parses the colon-separated entity line:
|
||||||
|
//
|
||||||
|
// "France: 14: 27: EU: 46.00: -2.00: -1.0: F:"
|
||||||
|
func parseEntityHeader(line string) *Entity {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) < 8 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
e := &Entity{
|
||||||
|
Name: strings.TrimSpace(parts[0]),
|
||||||
|
Continent: strings.TrimSpace(parts[3]),
|
||||||
|
Primary: strings.TrimSpace(parts[7]),
|
||||||
|
}
|
||||||
|
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
|
||||||
|
e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
|
||||||
|
e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64)
|
||||||
|
e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
|
||||||
|
if e.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrefix peels off cty.dat per-prefix annotations:
|
||||||
|
//
|
||||||
|
// K (5)[7]<35.50/-95.00>{NA}~America/Chicago~
|
||||||
|
// =W1AW
|
||||||
|
func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
|
||||||
|
out := prefixEntry{entity: e}
|
||||||
|
exact := false
|
||||||
|
if strings.HasPrefix(s, "=") {
|
||||||
|
exact = true
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
// Strip annotations. Order them roughly so we extract before they appear
|
||||||
|
// in the prefix slice.
|
||||||
|
s = stripAnnotation(s, '(', ')', func(v string) {
|
||||||
|
out.cqOverride, _ = strconv.Atoi(v)
|
||||||
|
})
|
||||||
|
s = stripAnnotation(s, '[', ']', func(v string) {
|
||||||
|
out.ituOverride, _ = strconv.Atoi(v)
|
||||||
|
})
|
||||||
|
s = stripAnnotation(s, '<', '>', func(v string) {
|
||||||
|
if a, b, ok := strings.Cut(v, "/"); ok {
|
||||||
|
lat, e1 := strconv.ParseFloat(a, 64)
|
||||||
|
lon, e2 := strconv.ParseFloat(b, 64)
|
||||||
|
if e1 == nil && e2 == nil {
|
||||||
|
out.latOverride, out.lonOverride = lat, lon
|
||||||
|
out.hasLatLon = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
s = stripAnnotation(s, '{', '}', func(v string) {
|
||||||
|
out.contOverride = strings.TrimSpace(v)
|
||||||
|
})
|
||||||
|
s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ })
|
||||||
|
out.prefix = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
return out, exact
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripAnnotation removes a single ...X...Y... block and invokes cb with the
|
||||||
|
// inner text. Used for (CQ), [ITU], <lat/lon>, {cont}, ~tz~ annotations.
|
||||||
|
func stripAnnotation(s string, open, close rune, cb func(string)) string {
|
||||||
|
i := strings.IndexRune(s, open)
|
||||||
|
if i < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
j := strings.IndexRune(s[i+1:], close)
|
||||||
|
if j < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cb(s[i+1 : i+1+j])
|
||||||
|
return s[:i] + s[i+1+j+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
|
||||||
|
// matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all
|
||||||
|
// keep the operator's home DXCC.
|
||||||
|
var suffixModifiers = map[string]bool{
|
||||||
|
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
|
||||||
|
"PM": true, "LH": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCallsign uppercases, trims, and resolves the "active" call when
|
||||||
|
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE).
|
||||||
|
func normalizeCallsign(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.ContainsRune(s, '/') {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, "/")
|
||||||
|
keep := parts[:0]
|
||||||
|
for _, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if suffixModifiers[p] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(p) == 1 && p >= "0" && p <= "9" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keep = append(keep, p)
|
||||||
|
}
|
||||||
|
switch len(keep) {
|
||||||
|
case 0:
|
||||||
|
return s
|
||||||
|
case 1:
|
||||||
|
return keep[0]
|
||||||
|
}
|
||||||
|
// Two non-modifier parts → operating-from prefix wins (shorter one).
|
||||||
|
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
|
||||||
|
if len(keep[0]) <= len(keep[1]) {
|
||||||
|
return keep[0]
|
||||||
|
}
|
||||||
|
return keep[1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dxcc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleCty = `Sov Mil Order of Malta: 15: 28: EU: 41.90: -12.43: -1.0: 1A:
|
||||||
|
1A;
|
||||||
|
Monaco: 14: 27: EU: 43.73: -7.40: -1.0: 3A:
|
||||||
|
3A;
|
||||||
|
France: 14: 27: EU: 46.00: -2.00: -1.0: F:
|
||||||
|
F,HW,HX,HY,TH,TM,TO,TP,TQ,TV,TX;
|
||||||
|
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||||
|
DA,DB,DC,DD,DE,DF,DG,DH,DI,DJ,DK,DL,DM,DN,DO,DP,DQ,DR;
|
||||||
|
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
|
||||||
|
=W1AW(5)[7],K,N,W,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK;
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestLookup(t *testing.T) {
|
||||||
|
db, err := Load(strings.NewReader(sampleCty))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
call string
|
||||||
|
wantEnt string
|
||||||
|
}{
|
||||||
|
{"F4NIE", "France"},
|
||||||
|
{"F4BPO", "France"},
|
||||||
|
{"F4BPO/P", "France"},
|
||||||
|
{"DL/F4NIE", "Germany"},
|
||||||
|
{"DL5XYZ", "Germany"},
|
||||||
|
{"K1ABC", "United States"},
|
||||||
|
{"N0CALL", "United States"},
|
||||||
|
{"3A2MD", "Monaco"},
|
||||||
|
{"W1AW", "United States"}, // exact match wins
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
m, ok := db.Lookup(c.call)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: no match", c.call)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.Entity.Name != c.wantEnt {
|
||||||
|
t.Errorf("%s: got %q, want %q", c.call, m.Entity.Name, c.wantEnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// W1AW exact match has CQ override 5 and ITU override 7.
|
||||||
|
m, _ := db.Lookup("W1AW")
|
||||||
|
if m.CQZone != 5 || m.ITUZone != 7 {
|
||||||
|
t.Errorf("W1AW overrides: got CQ=%d ITU=%d, want 5/7", m.CQZone, m.ITUZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalize(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"F4BPO": "F4BPO",
|
||||||
|
"f4bpo": "F4BPO",
|
||||||
|
" F4BPO ": "F4BPO",
|
||||||
|
"F4BPO/P": "F4BPO",
|
||||||
|
"F4BPO/MM": "F4BPO",
|
||||||
|
"F4BPO/5": "F4BPO",
|
||||||
|
"DL/F4BPO": "DL",
|
||||||
|
"F4BPO/W6": "W6",
|
||||||
|
"VK9/F4BPO": "VK9",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := normalizeCallsign(in); got != want {
|
||||||
|
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package dxcc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CtyDatURL is the canonical source of cty.dat. AD1C ships updates roughly
|
||||||
|
// monthly; we cache the file on disk so we don't hammer it.
|
||||||
|
const CtyDatURL = "https://www.country-files.com/cty/cty.dat"
|
||||||
|
|
||||||
|
// Manager owns the on-disk cty.dat cache and the parsed DB. Safe for
|
||||||
|
// concurrent reads after Load; concurrent reloads serialize on its lock.
|
||||||
|
type Manager struct {
|
||||||
|
cacheDir string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
db *DB
|
||||||
|
src ctySource // metadata about whichever copy we loaded
|
||||||
|
|
||||||
|
loading atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ctySource struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
LoadedAt time.Time `json:"loaded_at"`
|
||||||
|
FileModTime time.Time `json:"file_mod_time"`
|
||||||
|
Entities int `json:"entities"`
|
||||||
|
Downloaded bool `json:"downloaded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager prepares a manager rooted at cacheDir (created if missing).
|
||||||
|
// Does not load anything — call EnsureLoaded after.
|
||||||
|
func NewManager(cacheDir string) *Manager {
|
||||||
|
return &Manager{cacheDir: cacheDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the on-disk path where cty.dat is/should be cached.
|
||||||
|
func (m *Manager) Path() string {
|
||||||
|
return filepath.Join(m.cacheDir, "cty.dat")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureLoaded loads cty.dat from disk; if missing, downloads it first.
|
||||||
|
// Safe to call repeatedly — only the first run actually downloads.
|
||||||
|
func (m *Manager) EnsureLoaded(ctx context.Context) error {
|
||||||
|
if _, err := os.Stat(m.Path()); os.IsNotExist(err) {
|
||||||
|
if err := m.Download(ctx); err != nil {
|
||||||
|
return fmt.Errorf("download cty.dat: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.LoadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromDisk parses the cached cty.dat into a fresh DB and swaps it in.
|
||||||
|
func (m *Manager) LoadFromDisk() error {
|
||||||
|
f, err := os.Open(m.Path())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, _ := f.Stat()
|
||||||
|
db, err := Load(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.db = db
|
||||||
|
m.src = ctySource{
|
||||||
|
Path: m.Path(),
|
||||||
|
LoadedAt: time.Now(),
|
||||||
|
FileModTime: info.ModTime(),
|
||||||
|
Entities: len(db.entities),
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches a fresh cty.dat from CtyDatURL and atomically replaces
|
||||||
|
// the on-disk cache. Does NOT reload it into memory — caller can chain
|
||||||
|
// LoadFromDisk for that.
|
||||||
|
func (m *Manager) Download(ctx context.Context) error {
|
||||||
|
if !m.loading.CompareAndSwap(false, true) {
|
||||||
|
return fmt.Errorf("cty.dat download already in progress")
|
||||||
|
}
|
||||||
|
defer m.loading.Store(false)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", CtyDatURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Write to a temp file in the same dir, then atomic rename — avoids a
|
||||||
|
// half-written file if we crash mid-download.
|
||||||
|
tmp, err := os.CreateTemp(m.cacheDir, "cty-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
_, err = io.Copy(tmp, resp.Body)
|
||||||
|
tmp.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, m.Path()); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh = Download + LoadFromDisk in one call.
|
||||||
|
func (m *Manager) Refresh(ctx context.Context) error {
|
||||||
|
if err := m.Download(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.LoadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup is a passthrough to the loaded DB. Returns false if no DB is
|
||||||
|
// loaded yet (callers should treat that as graceful degradation).
|
||||||
|
func (m *Manager) Lookup(callsign string) (Match, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if db == nil {
|
||||||
|
return Match{}, false
|
||||||
|
}
|
||||||
|
return db.Lookup(callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info returns metadata about the currently-loaded cty.dat (or zero value
|
||||||
|
// if nothing loaded).
|
||||||
|
func (m *Manager) Info() ctySource {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.src
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package profile manages operator profiles: transmit callsign, locator,
|
||||||
|
// DXCC, operator, antenna/CAT config, etc. Lets the user switch quickly
|
||||||
|
// between several identities (home / portable / SOTA …).
|
||||||
|
// TODO: implementation.
|
||||||
|
package profile
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Package profile manages operator profiles: transmit callsign, locator,
|
||||||
|
// DXCC, operator, antenna/CAT config, etc. Lets the user switch quickly
|
||||||
|
// between several identities (home / portable / SOTA …).
|
||||||
|
//
|
||||||
|
// The active profile stamps every new QSO's MY_* fields (MY_GRIDSQUARE,
|
||||||
|
// MY_SOTA_REF, MY_RIG…). Future versions will also attach per-profile
|
||||||
|
// credentials (LoTW / Clublog / QRZ.com) so each callsign can export to
|
||||||
|
// its own account.
|
||||||
|
package profile
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile is one operating configuration. A user typically keeps a few:
|
||||||
|
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
||||||
|
type Profile struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Callsign string `json:"callsign"`
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
MyGrid string `json:"my_grid"`
|
||||||
|
MyCountry string `json:"my_country"`
|
||||||
|
MyState string `json:"my_state"`
|
||||||
|
MyCounty string `json:"my_cnty"`
|
||||||
|
MyStreet string `json:"my_street"`
|
||||||
|
MyCity string `json:"my_city"`
|
||||||
|
MyPostalCode string `json:"my_postal_code"`
|
||||||
|
MySOTARef string `json:"my_sota_ref"`
|
||||||
|
MyPOTARef string `json:"my_pota_ref"`
|
||||||
|
MyRig string `json:"my_rig"`
|
||||||
|
MyAntenna string `json:"my_antenna"`
|
||||||
|
TxPower *float64 `json:"tx_pwr,omitempty"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo is a SQLite-backed profile store. All ops take a context so the
|
||||||
|
// HTTP/Wails frontend can cancel them on tab close.
|
||||||
|
type Repo struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||||
|
|
||||||
|
const selectCols = `id, name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
||||||
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
|
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
|
||||||
|
|
||||||
|
// List returns every profile, active first then by sort_order/id.
|
||||||
|
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
|
||||||
|
FROM station_profiles
|
||||||
|
ORDER BY is_active DESC, sort_order ASC, id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Profile
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scan(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one profile by ID, or sql.ErrNoRows if missing.
|
||||||
|
func (r *Repo) Get(ctx context.Context, id int64) (Profile, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+`
|
||||||
|
FROM station_profiles WHERE id = ?`, id)
|
||||||
|
return scan(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active returns the currently-active profile, or sql.ErrNoRows if none.
|
||||||
|
func (r *Repo) Active(ctx context.Context) (Profile, error) {
|
||||||
|
row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+`
|
||||||
|
FROM station_profiles WHERE is_active = 1 LIMIT 1`)
|
||||||
|
return scan(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save upserts a profile. p.ID == 0 means "create". Updates touch
|
||||||
|
// updated_at; is_active is preserved separately via SetActive.
|
||||||
|
func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
||||||
|
if p.Name == "" {
|
||||||
|
return fmt.Errorf("profile name required")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
if p.ID == 0 {
|
||||||
|
res, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO station_profiles
|
||||||
|
(name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
||||||
|
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||||
|
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
|
||||||
|
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
||||||
|
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
||||||
|
p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
p.ID = id
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
UPDATE station_profiles SET
|
||||||
|
name = ?, callsign = ?, operator = ?, my_grid = ?, my_country = ?,
|
||||||
|
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
||||||
|
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
|
||||||
|
sort_order = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry,
|
||||||
|
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
||||||
|
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
|
||||||
|
p.SortOrder, now, p.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActive atomically switches the active profile. Clears the flag on all
|
||||||
|
// rows first to keep the "only one active" invariant from the schema doc.
|
||||||
|
func (r *Repo) SetActive(ctx context.Context, id int64) error {
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a profile. Refuses to delete the last remaining profile
|
||||||
|
// (we always want at least one so QSO stamping doesn't crash). If the
|
||||||
|
// deleted one was active, the first remaining profile becomes active.
|
||||||
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||||
|
var count int
|
||||||
|
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count <= 1 {
|
||||||
|
return fmt.Errorf("cannot delete the last remaining profile")
|
||||||
|
}
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var wasActive int
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT is_active FROM station_profiles WHERE id = ?`, id).Scan(&wasActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM station_profiles WHERE id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wasActive == 1 {
|
||||||
|
// Promote the first remaining profile.
|
||||||
|
var newID int64
|
||||||
|
if err := tx.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&newID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, newID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate clones a profile under a new name (caller supplies it). The
|
||||||
|
// copy is created inactive — switching is an explicit user action.
|
||||||
|
func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Profile, error) {
|
||||||
|
src, err := r.Get(ctx, srcID)
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
src.ID = 0
|
||||||
|
src.Name = newName
|
||||||
|
src.IsActive = false
|
||||||
|
src.SortOrder = 0
|
||||||
|
if err := r.Save(ctx, &src); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
|
||||||
|
type scannable interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scan(row scannable) (Profile, error) {
|
||||||
|
var p Profile
|
||||||
|
var (
|
||||||
|
callsign, operator, myGrid, myCountry, myState, myCnty,
|
||||||
|
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
||||||
|
myRig, myAntenna sql.NullString
|
||||||
|
txPwr sql.NullFloat64
|
||||||
|
isActive, sortOrder int
|
||||||
|
createdAt, updatedAt string
|
||||||
|
)
|
||||||
|
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &myGrid, &myCountry, &myState, &myCnty,
|
||||||
|
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
||||||
|
&myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
p.Callsign = callsign.String
|
||||||
|
p.Operator = operator.String
|
||||||
|
p.MyGrid = myGrid.String
|
||||||
|
p.MyCountry = myCountry.String
|
||||||
|
p.MyState = myState.String
|
||||||
|
p.MyCounty = myCnty.String
|
||||||
|
p.MyStreet = myStreet.String
|
||||||
|
p.MyCity = myCity.String
|
||||||
|
p.MyPostalCode = myPostal.String
|
||||||
|
p.MySOTARef = mySOTA.String
|
||||||
|
p.MyPOTARef = myPOTA.String
|
||||||
|
p.MyRig = myRig.String
|
||||||
|
p.MyAntenna = myAntenna.String
|
||||||
|
if txPwr.Valid {
|
||||||
|
v := txPwr.Float64
|
||||||
|
p.TxPower = &v
|
||||||
|
}
|
||||||
|
p.IsActive = isActive == 1
|
||||||
|
p.SortOrder = sortOrder
|
||||||
|
p.CreatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", createdAt)
|
||||||
|
p.UpdatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", updatedAt)
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableFloat(p *float64) any {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsReader is the minimal slice of the settings store we need for
|
||||||
|
// migrating legacy single-station settings into the first profile.
|
||||||
|
type SettingsReader interface {
|
||||||
|
GetMany(ctx context.Context, keys ...string) (map[string]string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDefault makes sure at least one profile exists and one is active.
|
||||||
|
//
|
||||||
|
// First-run path: if the table is empty, build a "Default" profile from
|
||||||
|
// the legacy `station.*` settings keys and mark it active — so the user's
|
||||||
|
// existing config carries over invisibly. Returns the active profile.
|
||||||
|
//
|
||||||
|
// The legacy key names are passed in from the caller (app.go) so this
|
||||||
|
// package doesn't need to import or duplicate them.
|
||||||
|
func EnsureDefault(ctx context.Context, db *sql.DB, settings SettingsReader, legacyKeys LegacyStationKeys) (Profile, error) {
|
||||||
|
repo := NewRepo(db)
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
seed, err := buildSeedFromSettings(ctx, settings, legacyKeys)
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
seed.IsActive = true
|
||||||
|
if err := repo.Save(ctx, &seed); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
return seed, nil
|
||||||
|
}
|
||||||
|
// Profiles exist but none active (manual DB edit, deleted active row
|
||||||
|
// outside the app, …) — promote the first one.
|
||||||
|
if active, err := repo.Active(ctx); err == nil {
|
||||||
|
return active, nil
|
||||||
|
}
|
||||||
|
var firstID int64
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&firstID); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
if err := repo.SetActive(ctx, firstID); err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
return repo.Get(ctx, firstID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyStationKeys names the pre-profiles settings keys used to seed
|
||||||
|
// the first profile. Kept as a struct so adding/renaming a key doesn't
|
||||||
|
// silently fall through to an empty default.
|
||||||
|
type LegacyStationKeys struct {
|
||||||
|
Callsign string
|
||||||
|
Operator string
|
||||||
|
MyGrid string
|
||||||
|
Country string
|
||||||
|
SOTA string
|
||||||
|
POTA string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSeedFromSettings(ctx context.Context, settings SettingsReader, keys LegacyStationKeys) (Profile, error) {
|
||||||
|
m, err := settings.GetMany(ctx,
|
||||||
|
keys.Callsign, keys.Operator, keys.MyGrid, keys.Country, keys.SOTA, keys.POTA)
|
||||||
|
if err != nil {
|
||||||
|
return Profile{}, err
|
||||||
|
}
|
||||||
|
return Profile{
|
||||||
|
Name: "Default",
|
||||||
|
Callsign: m[keys.Callsign],
|
||||||
|
Operator: m[keys.Operator],
|
||||||
|
MyGrid: m[keys.MyGrid],
|
||||||
|
MyCountry: m[keys.Country],
|
||||||
|
MySOTARef: m[keys.SOTA],
|
||||||
|
MyPOTARef: m[keys.POTA],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestArgsMatchesColumnCount makes sure args() stays in lock-step with columnList.
|
||||||
|
// If you add or remove a column in columnList, you MUST add or remove the
|
||||||
|
// matching field in args() — this test catches drift the compiler can't.
|
||||||
|
func TestArgsMatchesColumnCount(t *testing.T) {
|
||||||
|
q := QSO{}
|
||||||
|
args := q.args()
|
||||||
|
if len(args) != columnCount {
|
||||||
|
t.Fatalf("args() returns %d values, columnList has %d columns", len(args), columnCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Package rotator drives antenna rotators. Target backend: PstRotator (TCP).
|
||||||
|
// TODO: implementation.
|
||||||
|
package rotator
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package settings is a tiny key/value store backed by the SQLite settings table.
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||||
|
|
||||||
|
// Get returns the value for key, or "" if not set.
|
||||||
|
func (s *Store) Get(ctx context.Context, key string) (string, error) {
|
||||||
|
var v string
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set upserts a key/value pair.
|
||||||
|
func (s *Store) Set(ctx context.Context, key, value string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO settings(key, value) VALUES(?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
|
||||||
|
key, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns every stored setting. Used by the UI to populate the prefs panel.
|
||||||
|
func (s *Store) All(ctx context.Context) (map[string]string, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMany fetches several keys in a single round-trip.
|
||||||
|
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
|
||||||
|
out := make(map[string]string, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
v, err := s.Get(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create an instance of the app structure
|
||||||
|
app := NewApp()
|
||||||
|
|
||||||
|
// Create application with options
|
||||||
|
err := wails.Run(&options.App{
|
||||||
|
Title: "HamLog",
|
||||||
|
Width: 1400,
|
||||||
|
Height: 900,
|
||||||
|
MinWidth: 1100,
|
||||||
|
MinHeight: 700,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: assets,
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
|
||||||
|
OnStartup: app.startup,
|
||||||
|
OnShutdown: app.shutdown,
|
||||||
|
Bind: []interface{}{
|
||||||
|
app,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
println("Error:", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "HamLog",
|
||||||
|
"outputfilename": "HamLog",
|
||||||
|
"frontend:install": "npm install",
|
||||||
|
"frontend:build": "npm run build",
|
||||||
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
"frontend:dev:serverUrl": "auto",
|
||||||
|
"author": {
|
||||||
|
"name": "rouggy",
|
||||||
|
"email": "legreg002@hotmail.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user