Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Package db handles the SQLite connection and migrations.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Open opens (and creates if needed) the SQLite database at the given path,
|
||||
// enables performance PRAGMAs, and applies embedded migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", path)
|
||||
conn, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
if err := migrate(conn); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// migrate applies all embedded *.sql migrations in alphabetical order,
|
||||
// skipping those already applied. Intentionally minimal in-house system
|
||||
// (no external dependency).
|
||||
func migrate(conn *sql.DB) error {
|
||||
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
var dummy string
|
||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue // already applied
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
}
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(string(content)); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- HamLog initial schema
|
||||
-- QSO table: core of the logbook. Field names stay close to ADIF.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qso (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
callsign TEXT NOT NULL,
|
||||
qso_date TEXT NOT NULL, -- ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ
|
||||
band TEXT NOT NULL, -- e.g. 20m, 40m, 2m
|
||||
mode TEXT NOT NULL, -- e.g. SSB, CW, FT8
|
||||
freq_hz INTEGER, -- frequency in Hz (integer, avoids floats)
|
||||
rst_sent TEXT,
|
||||
rst_rcvd TEXT,
|
||||
name TEXT,
|
||||
qth TEXT,
|
||||
grid TEXT,
|
||||
country TEXT,
|
||||
dxcc INTEGER,
|
||||
cont TEXT,
|
||||
cqz INTEGER,
|
||||
ituz INTEGER,
|
||||
iota TEXT,
|
||||
sota_ref TEXT,
|
||||
pota_ref TEXT,
|
||||
-- Operator context (multi-callsign / multi-location)
|
||||
station_callsign TEXT,
|
||||
operator TEXT,
|
||||
my_grid TEXT,
|
||||
my_country TEXT,
|
||||
my_sota_ref TEXT,
|
||||
my_pota_ref TEXT,
|
||||
-- Misc
|
||||
tx_pwr REAL,
|
||||
comment TEXT,
|
||||
notes TEXT,
|
||||
qsl_sent TEXT DEFAULT 'N',
|
||||
qsl_rcvd TEXT DEFAULT 'N',
|
||||
lotw_sent TEXT DEFAULT 'N',
|
||||
lotw_rcvd TEXT DEFAULT 'N',
|
||||
eqsl_sent TEXT DEFAULT 'N',
|
||||
eqsl_rcvd TEXT DEFAULT 'N',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_callsign ON qso(callsign);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_date ON qso(qso_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_band_mode ON qso(band, mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_grid ON qso(grid);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_station ON qso(station_callsign);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Key/value app settings (lookup credentials, preferences, …)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
-- Local cache of QRZ/HamQTH lookups to avoid re-querying for the same call.
|
||||
CREATE TABLE IF NOT EXISTS callsign_cache (
|
||||
callsign TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
qth TEXT,
|
||||
country TEXT,
|
||||
grid TEXT,
|
||||
dxcc INTEGER,
|
||||
cqz INTEGER,
|
||||
ituz INTEGER,
|
||||
cont TEXT,
|
||||
source TEXT NOT NULL, -- 'qrz' | 'hamqth'
|
||||
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Expand the QSO table to cover the rest of the common ADIF fields.
|
||||
-- SQLite ALTER TABLE ADD COLUMN is metadata-only, so this is fast even on
|
||||
-- large logbooks. Anything not promoted to a column lives in extras_json.
|
||||
|
||||
-- --- Times / frequencies / mode ---
|
||||
ALTER TABLE qso ADD COLUMN qso_date_off TEXT; -- ISO UTC end datetime
|
||||
ALTER TABLE qso ADD COLUMN freq_rx_hz INTEGER; -- RX frequency for split operation
|
||||
ALTER TABLE qso ADD COLUMN band_rx TEXT;
|
||||
ALTER TABLE qso ADD COLUMN submode TEXT; -- USB, LSB, USB-DATA, ...
|
||||
|
||||
-- --- Contacted station extras ---
|
||||
ALTER TABLE qso ADD COLUMN state TEXT; -- US state, JA prefecture, etc.
|
||||
ALTER TABLE qso ADD COLUMN cnty TEXT;
|
||||
ALTER TABLE qso ADD COLUMN address TEXT;
|
||||
ALTER TABLE qso ADD COLUMN email TEXT;
|
||||
ALTER TABLE qso ADD COLUMN web TEXT;
|
||||
ALTER TABLE qso ADD COLUMN age INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN lat REAL;
|
||||
ALTER TABLE qso ADD COLUMN lon REAL;
|
||||
ALTER TABLE qso ADD COLUMN gridsquare_ext TEXT; -- 8/10-char extension
|
||||
ALTER TABLE qso ADD COLUMN vucc_grids TEXT;
|
||||
ALTER TABLE qso ADD COLUMN rig TEXT; -- contacted station's rig
|
||||
ALTER TABLE qso ADD COLUMN ant TEXT; -- contacted station's antenna
|
||||
|
||||
-- --- QSL bureau / direct / LoTW / eQSL / Clublog / HRDLog ---
|
||||
ALTER TABLE qso ADD COLUMN qsl_via TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qsl_msg TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qslmsg_rcvd TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qsl_sent_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN qsl_rcvd_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN lotw_sent_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN lotw_rcvd_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN eqsl_sent_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN eqsl_rcvd_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_upload_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN clublog_qso_upload_status TEXT;
|
||||
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_date TEXT;
|
||||
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_status TEXT;
|
||||
|
||||
-- --- Contest ---
|
||||
ALTER TABLE qso ADD COLUMN contest_id TEXT;
|
||||
ALTER TABLE qso ADD COLUMN srx INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN stx INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN srx_string TEXT;
|
||||
ALTER TABLE qso ADD COLUMN stx_string TEXT;
|
||||
ALTER TABLE qso ADD COLUMN check_field TEXT; -- ADIF CHECK (reserved word in SQL)
|
||||
ALTER TABLE qso ADD COLUMN precedence TEXT;
|
||||
ALTER TABLE qso ADD COLUMN arrl_sect TEXT;
|
||||
|
||||
-- --- Satellite / propagation ---
|
||||
ALTER TABLE qso ADD COLUMN prop_mode TEXT;
|
||||
ALTER TABLE qso ADD COLUMN sat_name TEXT;
|
||||
ALTER TABLE qso ADD COLUMN sat_mode TEXT;
|
||||
ALTER TABLE qso ADD COLUMN ant_az REAL;
|
||||
ALTER TABLE qso ADD COLUMN ant_el REAL;
|
||||
ALTER TABLE qso ADD COLUMN ant_path TEXT;
|
||||
|
||||
-- --- My station extras (per-QSO overrides of the active profile) ---
|
||||
ALTER TABLE qso ADD COLUMN my_state TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_cnty TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_iota TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_dxcc INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN my_cq_zone INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN my_itu_zone INTEGER;
|
||||
ALTER TABLE qso ADD COLUMN my_lat REAL;
|
||||
ALTER TABLE qso ADD COLUMN my_lon REAL;
|
||||
ALTER TABLE qso ADD COLUMN my_street TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_city TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_postal_code TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_rig TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_antenna TEXT;
|
||||
ALTER TABLE qso ADD COLUMN my_gridsquare_ext TEXT;
|
||||
|
||||
-- --- Catch-all for ADIF fields we don't promote to columns ---
|
||||
-- JSON object: { "FIELD_NAME": "value", ... } (keys uppercase as in ADIF).
|
||||
ALTER TABLE qso ADD COLUMN extras_json TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_state ON qso(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_contest_id ON qso(contest_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_sat_name ON qso(sat_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_qso_prop_mode ON qso(prop_mode);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Extra columns captured from QRZ/HamQTH lookups, mapped onto F2 Info fields.
|
||||
ALTER TABLE callsign_cache ADD COLUMN address TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN state TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN cnty TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN lat REAL;
|
||||
ALTER TABLE callsign_cache ADD COLUMN lon REAL;
|
||||
ALTER TABLE callsign_cache ADD COLUMN email TEXT;
|
||||
ALTER TABLE callsign_cache ADD COLUMN qsl_via TEXT;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- station_profiles: one row per operating configuration (home, portable,
|
||||
-- SOTA, /MM, contest…). The user picks one as active; every QSO stamps
|
||||
-- the active profile's MY_* fields. Place reserved for per-profile creds
|
||||
-- (LoTW, Clublog, QRZ.com) in a later migration once those exports land.
|
||||
CREATE TABLE station_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, -- "Home", "Portable", "SOTA"…
|
||||
callsign TEXT NOT NULL DEFAULT '',
|
||||
operator TEXT NOT NULL DEFAULT '',
|
||||
my_grid TEXT NOT NULL DEFAULT '',
|
||||
my_country TEXT NOT NULL DEFAULT '',
|
||||
my_state TEXT NOT NULL DEFAULT '',
|
||||
my_cnty TEXT NOT NULL DEFAULT '',
|
||||
my_street TEXT NOT NULL DEFAULT '',
|
||||
my_city TEXT NOT NULL DEFAULT '',
|
||||
my_postal_code TEXT NOT NULL DEFAULT '',
|
||||
my_sota_ref TEXT NOT NULL DEFAULT '',
|
||||
my_pota_ref TEXT NOT NULL DEFAULT '',
|
||||
my_rig TEXT NOT NULL DEFAULT '',
|
||||
my_antenna TEXT NOT NULL DEFAULT '',
|
||||
tx_pwr REAL, -- nullable: not always known
|
||||
is_active INTEGER NOT NULL DEFAULT 0, -- 1 for the currently-selected profile
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
-- Only one profile can be active at a time. Enforced lazily — the Go side
|
||||
-- clears all then sets one before each switch.
|
||||
CREATE INDEX idx_station_profiles_active ON station_profiles(is_active);
|
||||
Reference in New Issue
Block a user