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:
2026-05-26 00:16:45 +02:00
parent 734d296300
commit 7ace2cc602
87 changed files with 15892 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(go get *)",
"Bash(go build *)",
"Bash(wails generate *)",
"Bash(npm run *)"
]
}
}
+47
View File
@@ -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
+938
View File
@@ -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)
}
+35
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+68
View File
@@ -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>
+63
View File
@@ -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

+15
View File
@@ -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}}"
}
}
}
+114
View File
@@ -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
+249
View File
@@ -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
+15
View File
@@ -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>
+172
View File
@@ -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)
}
}
+13
View File
@@ -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>
+3774
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
58f02c99f9fceb8f5aeae2c8b90fd325
+1357
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+256
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+373
View File
@@ -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>
);
}
+922
View File
@@ -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,
};
+30
View File
@@ -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 };
+44
View File
@@ -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 };
+25
View File
@@ -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 };
+89
View File
@@ -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,
};
+21
View File
@@ -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 };
+18
View File
@@ -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';
+124
View File
@@ -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,
};
+23
View File
@@ -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 };
+46
View File
@@ -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 };
+20
View File
@@ -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 };
+25
View File
@@ -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 };
+92
View File
@@ -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 (0360). 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; }
+11
View File
@@ -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));
}
+14
View File
@@ -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>
)
+72
View File
@@ -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; }
+19
View File
@@ -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'>;
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+24
View File
@@ -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" }]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -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)),
},
},
});
+78
View File
@@ -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>;
+143
View File
@@ -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);
}
+776
View File
@@ -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;
}
}
}
+24
View File
@@ -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
View File
@@ -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
+242
View File
@@ -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);
}
+46
View File
@@ -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
+125
View File
@@ -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=
+364
View File
@@ -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
}
+115
View File
@@ -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)
}
}
+84
View File
@@ -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"])
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package antenna drives antennas (Ultrabeam in particular).
// TODO: implementation.
package antenna
+4
View File
@@ -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
+5
View File
@@ -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
+322
View File
@@ -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 ""
}
+42
View File
@@ -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")
}
+321
View File
@@ -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 ""
}
+4
View File
@@ -0,0 +1,4 @@
// Package cluster provides a DX cluster client (telnet) with filters
// (band, mode, callsign, continent, ITU/CQ, …).
// TODO: implementation.
package cluster
+90
View File
@@ -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
}
+50
View File
@@ -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);
+21
View File
@@ -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;
+30
View File
@@ -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);
+293
View File
@@ -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]
}
+74
View File
@@ -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)
}
}
}
+156
View File
@@ -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
}
+185
View File
@@ -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"`
}
+304
View File
@@ -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
}
+235
View File
@@ -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 ""
}
+5
View File
@@ -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
+257
View File
@@ -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
}
+82
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package rotator drives antenna rotators. Target backend: PstRotator (TCP).
// TODO: implementation.
package rotator
+69
View File
@@ -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
}
+39
View File
@@ -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
View File
@@ -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"
}
}