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