rigs completed

This commit is contained in:
2026-05-28 18:35:22 +02:00
parent d3c9982c66
commit e8cac569e3
26 changed files with 3834 additions and 391 deletions
+50 -6
View File
@@ -1,12 +1,17 @@
package adif
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
"hamlog/internal/qso"
)
@@ -33,23 +38,62 @@ type Importer struct {
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
}
// ImportFile opens the file at path and imports it into the repo.
// ImportFile reads the file at path and imports it into the repo. The
// whole file is loaded into memory so we can do a definitive UTF-8 check
// before parsing — peeking a buffered window misses non-ASCII bytes that
// only appear past the header (typical when the ADIF header is pure ASCII
// but record fields like NAME/QTH have accented chars in Windows-1252).
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil {
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
return im.Import(ctx, f)
// Strip UTF-8 BOM if present so the parser sees clean data.
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return im.importBytes(ctx, data)
}
// Import streams the ADI content from r into the repo.
// pickValueDecoder returns the per-field byte-to-string decoder to use.
// If the file is valid UTF-8 we keep the bytes as-is; otherwise we assume
// Windows-1252 (de-facto encoding of MixW, Log4OM, HRD and most legacy
// Western-European loggers). Decoding has to happen on each field's bytes
// individually, NOT by wrapping the reader, because ADIF declares field
// lengths in source-encoding bytes — e.g. "<QTH:7>YAOUNDÉ" is 7 bytes in
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
// two bytes, and the parser reading 7 bytes after the tag would chop the
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND" after JSON.
func pickValueDecoder(data []byte) func([]byte) string {
if utf8.Valid(data) {
return nil // identity
}
dec := charmap.Windows1252.NewDecoder()
return func(b []byte) string {
out, err := dec.Bytes(b)
if err != nil {
return string(b)
}
return string(out)
}
}
// Import streams the ADI content from r into the repo. Assumes UTF-8;
// callers that may receive other encodings should go through ImportFile.
func (im *Importer) Import(ctx context.Context, r interface {
Read(p []byte) (int, error)
}) (ImportResult, error) {
data, err := io.ReadAll(r)
if err != nil {
return ImportResult{}, fmt.Errorf("read input: %w", err)
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return im.importBytes(ctx, data)
}
func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult, error) {
if im.BatchSize <= 0 {
im.BatchSize = 500
}
decode := pickValueDecoder(data)
res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize)
@@ -73,7 +117,7 @@ func (im *Importer) Import(ctx context.Context, r interface {
return err
}
err = Parse(r, func(rec Record) error {
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
res.Total++
q, ok := recordToQSO(rec)
if !ok {
+20 -1
View File
@@ -25,6 +25,21 @@ type Record map[string]string
// 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 {
return parseWith(r, nil, fn)
}
// ParseWithDecoder is like Parse but applies decodeValue to each field's
// raw bytes before storing as a string. ADIF field lengths are byte
// counts in the file's native encoding, so decoding MUST happen after
// reading exactly N bytes — wrapping the reader in a decoder would shift
// byte boundaries and chop multibyte chars in half (e.g. "<QTH:7>YAOUNDÉ"
// in Windows-1252 is 7 bytes; after upfront decoding it'd be 8 bytes of
// UTF-8 and the parser would only read the first 7, splitting É).
func ParseWithDecoder(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
return parseWith(r, decodeValue, fn)
}
func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) error) error {
br := bufio.NewReaderSize(r, 64*1024)
rec := Record{}
@@ -69,7 +84,11 @@ func Parse(r io.Reader, fn func(Record) error) error {
return fmt.Errorf("read field %s: %w", name, err)
}
if headerDone && name != "" {
rec[name] = string(val)
if decodeValue != nil {
rec[name] = decodeValue(val)
} else {
rec[name] = string(val)
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
// Package backup creates rotating copies of the SQLite logbook so a single
// disk failure or accidental delete doesn't wipe the user's QSO history.
// The strategy is intentionally simple: a daily snapshot of the .db file
// (optionally zipped), keeping the last N. SQLite's atomic-commit format
// lets us copy the raw file safely as long as we copy after a checkpoint.
package backup
import (
"archive/zip"
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Settings holds the user-tweakable backup configuration. Stored as flat
// key/value pairs in the settings table (so we don't need a schema change
// every time we add a field).
type Settings struct {
Enabled bool `json:"enabled"`
Folder string `json:"folder"` // empty → DefaultFolder
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
Zip bool `json:"zip"` // compress with deflate
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never
}
// DefaultFolder returns the folder used when Settings.Folder is empty.
// It sits next to the database file (in <appdata>/hamlog/backups) which
// keeps it portable when the user moves the data directory.
func DefaultFolder(dataDir string) string {
return filepath.Join(dataDir, "backups")
}
// Run executes one backup pass: checkpoint WAL → copy the database file
// → optionally zip → rotate. Returns the path of the file that was
// written so the caller can surface it to the UI.
//
// dbConn is used to issue the WAL checkpoint so the on-disk file is
// self-consistent before we copy it. It's the same *sql.DB the app uses;
// SQLite tolerates concurrent reads during the copy.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
}
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
// Flush WAL into the main file so a raw copy is a complete database.
// TRUNCATE removes the -wal file's contents after checkpointing.
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
return "", fmt.Errorf("wal_checkpoint: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("hamlog-%s", stamp)
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".db.zip")
if err := copyZipped(dbPath, dstPath); err != nil {
return "", err
}
} else {
dstPath = filepath.Join(folder, base+".db")
if err := copyFile(dbPath, dstPath); err != nil {
return "", err
}
}
if err := rotate(folder, rotation); err != nil {
// Rotation errors are non-fatal — the backup itself succeeded.
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return fmt.Errorf("copy: %w", err)
}
return out.Close()
}
// copyZipped writes a single-entry deflate zip containing the database.
// The inner filename is just the base of the source so unzip restores
// "hamlog.db" wherever the user extracts.
func copyZipped(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
zw := zip.NewWriter(out)
w, err := zw.Create(filepath.Base(src))
if err != nil {
zw.Close()
out.Close()
os.Remove(dst)
return fmt.Errorf("zip entry: %w", err)
}
if _, err := io.Copy(w, in); err != nil {
zw.Close()
out.Close()
os.Remove(dst)
return fmt.Errorf("zip write: %w", err)
}
if err := zw.Close(); err != nil {
out.Close()
os.Remove(dst)
return fmt.Errorf("zip close: %w", err)
}
return out.Close()
}
// rotate keeps the most recent `keep` backups in folder and deletes the
// rest. Only files matching the hamlog-*.db / hamlog-*.db.zip pattern
// are touched — never user files that happen to live in the same folder.
func rotate(folder string, keep int) error {
entries, err := os.ReadDir(folder)
if err != nil {
return err
}
type item struct {
path string
mod time.Time
}
matches := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "hamlog-") {
continue
}
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()})
}
if len(matches) <= keep {
return nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) })
var firstErr error
for _, m := range matches[keep:] {
if err := os.Remove(m.path); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// HasBackupToday returns true if a backup for today's date already exists
// in folder. Used by the startup auto-backup to skip when the user has
// already restarted the app once today.
func HasBackupToday(folder string) bool {
if folder == "" {
return false
}
stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} {
if _, err := os.Stat(filepath.Join(folder, "hamlog-"+stamp+ext)); err == nil {
return true
}
}
return false
}
+19 -17
View File
@@ -40,20 +40,25 @@ type ServerConfig struct {
// is emitted to the UI, so the table never has empty country cells
// flickering in for a few hundred ms.
type Spot struct {
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"`
Comment string `json:"comment,omitempty"`
Locator string `json:"locator,omitempty"` // spotter grid (optional)
TimeUTC string `json:"time_utc,omitempty"`
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent
ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"`
SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"`
Comment string `json:"comment,omitempty"`
Locator string `json:"locator,omitempty"` // spotter grid (optional)
TimeUTC string `json:"time_utc,omitempty"`
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"`
}
// State enumerates the per-server lifecycle.
@@ -168,14 +173,11 @@ func (m *Manager) StopServer(id int64) {
if ok {
delete(m.sessions, id)
}
remaining := len(m.sessions)
m.mu.Unlock()
fmt.Printf("cluster.StopServer id=%d found=%v remaining=%d\n", id, ok, remaining)
if !ok {
return
}
s.stop()
fmt.Printf("cluster.StopServer id=%d stopped successfully\n", id)
m.emitStatus()
}
+50
View File
@@ -0,0 +1,50 @@
-- Operating conditions: per-profile tree of stations (radios) → antennas →
-- bands. Used to auto-populate MY_RIG / MY_ANTENNA on each logged QSO
-- based on the current band, so the operator doesn't have to retype them.
--
-- Tree shape:
-- profile
-- └── station (one row per radio: name + ADIF MY_RIG value)
-- └── antenna (one row per antenna for that radio: name + ADIF MY_ANTENNA)
-- └── band (band tags the antenna covers; one may be flagged default)
--
-- "Default for a band" is a per-profile flag: when the user picks 20m in the
-- entry strip and an antenna on 20m is marked default, MY_RIG and MY_ANTENNA
-- auto-fill from that antenna and its parent station. At most one antenna
-- can be the default for any given (profile, band) — enforced by a partial
-- unique index below.
CREATE TABLE operating_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
name TEXT NOT NULL, -- display name, e.g. "Flex 8600"
adif_rig TEXT NOT NULL DEFAULT '', -- value written to MY_RIG ADIF field
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')),
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
CREATE TABLE operating_antennas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_id INTEGER NOT NULL,
name TEXT NOT NULL, -- e.g. "UB640 VL2.3"
adif_ant TEXT NOT NULL DEFAULT '', -- value written to MY_ANTENNA ADIF field
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')),
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
-- The bands an antenna covers. Composite PK = one row per (antenna, band).
-- is_default = the entry-form autofill picks this row when the user sets band.
CREATE TABLE operating_antenna_bands (
antenna_id INTEGER NOT NULL,
band TEXT NOT NULL, -- ADIF lowercase, e.g. "20m"
is_default INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (antenna_id, band),
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
@@ -0,0 +1,42 @@
-- Repair the operating_* tables: 0008 referenced a non-existent `profiles`
-- table (the real table is `station_profiles`), so the FK validation
-- failed on every insert with "no such table: main.profiles". Dropping
-- and recreating is safe here because no operating data could have been
-- inserted (every attempt errored out).
DROP TABLE IF EXISTS operating_antenna_bands;
DROP TABLE IF EXISTS operating_antennas;
DROP TABLE IF EXISTS operating_stations;
CREATE TABLE operating_stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
name TEXT NOT NULL,
adif_rig TEXT NOT NULL DEFAULT '',
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')),
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
CREATE TABLE operating_antennas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_id INTEGER NOT NULL,
name TEXT NOT NULL,
adif_ant TEXT NOT NULL DEFAULT '',
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')),
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
CREATE TABLE operating_antenna_bands (
antenna_id INTEGER NOT NULL,
band TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (antenna_id, band),
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
@@ -0,0 +1,51 @@
-- Simplify the operating tree: drop the separate ADIF-value columns (we
-- now use the display name as the ADIF MY_RIG / MY_ANTENNA value — one
-- field per row, no duplication) and add per-rig TX power so the entry
-- strip can stamp TX_PWR alongside MY_RIG when the band changes.
-- SQLite can't DROP COLUMN safely on every version we support, so we
-- recreate the tables. operating_antenna_bands is left untouched — its
-- schema didn't change — but the FK on operating_antennas needs to be
-- rewired since the table is recreated.
DROP TABLE IF EXISTS operating_antenna_bands;
CREATE TABLE operating_stations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
name TEXT NOT NULL,
tx_pwr REAL,
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')),
FOREIGN KEY (profile_id) REFERENCES station_profiles(id) ON DELETE CASCADE
);
INSERT INTO operating_stations_new (id, profile_id, name, sort_order, created_at, updated_at)
SELECT id, profile_id, name, sort_order, created_at, updated_at FROM operating_stations;
DROP TABLE operating_stations;
ALTER TABLE operating_stations_new RENAME TO operating_stations;
CREATE INDEX idx_operating_stations_profile ON operating_stations(profile_id, sort_order);
CREATE TABLE operating_antennas_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
station_id INTEGER NOT NULL,
name TEXT NOT NULL,
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')),
FOREIGN KEY (station_id) REFERENCES operating_stations(id) ON DELETE CASCADE
);
INSERT INTO operating_antennas_new (id, station_id, name, sort_order, created_at, updated_at)
SELECT id, station_id, name, sort_order, created_at, updated_at FROM operating_antennas;
DROP TABLE operating_antennas;
ALTER TABLE operating_antennas_new RENAME TO operating_antennas;
CREATE INDEX idx_operating_antennas_station ON operating_antennas(station_id, sort_order);
CREATE TABLE operating_antenna_bands (
antenna_id INTEGER NOT NULL,
band TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (antenna_id, band),
FOREIGN KEY (antenna_id) REFERENCES operating_antennas(id) ON DELETE CASCADE
);
CREATE INDEX idx_operating_bands_band ON operating_antenna_bands(band, is_default);
+324
View File
@@ -0,0 +1,324 @@
// Package operating manages the per-profile tree of stations (radios),
// antennas, and the bands each antenna covers. The "default for a band"
// flag drives the auto-fill of MY_RIG / MY_ANTENNA on each logged QSO.
package operating
import (
"context"
"database/sql"
"fmt"
"strings"
)
// Station is a radio / TRX line. The display Name is also what gets
// written into the MY_RIG ADIF field on each QSO — no separate ADIF
// value to maintain. TXPower (W) is per-rig so changing rig auto-
// stamps the right power on logged QSOs.
type Station struct {
ID int64 `json:"id"`
ProfileID int64 `json:"profile_id"`
Name string `json:"name"`
TXPower *float64 `json:"tx_pwr,omitempty"`
SortOrder int `json:"sort_order"`
Antennas []Antenna `json:"antennas,omitempty"`
}
// Antenna is one antenna attached to a station. The display Name doubles
// as the MY_ANTENNA ADIF value.
type Antenna struct {
ID int64 `json:"id"`
StationID int64 `json:"station_id"`
Name string `json:"name"`
SortOrder int `json:"sort_order"`
Bands []AntennaBand `json:"bands"`
}
// AntennaBand pairs an antenna with one of the bands it covers, plus
// whether it is the default for that band in this profile.
type AntennaBand struct {
Band string `json:"band"`
IsDefault bool `json:"is_default"`
}
// BandDefault is the resolved tuple looked up at QSO save: which
// station+antenna should pre-fill MY_RIG / MY_ANTENNA / TX_PWR for a
// given band. Station and antenna names go straight into the ADIF
// fields — there is no separate "ADIF value" anymore.
type BandDefault struct {
StationID int64 `json:"station_id"`
StationName string `json:"station_name"`
AntennaID int64 `json:"antenna_id"`
AntennaName string `json:"antenna_name"`
TXPower *float64 `json:"tx_pwr,omitempty"`
}
type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// ListTree returns every station for the profile with its nested antennas
// and bands. One round-trip per level — three queries total regardless of
// tree size, so the Settings panel stays snappy on big setups.
func (r *Repo) ListTree(ctx context.Context, profileID int64) ([]Station, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, profile_id, name, tx_pwr, sort_order
FROM operating_stations
WHERE profile_id = ?
ORDER BY sort_order, id`, profileID)
if err != nil {
return nil, fmt.Errorf("list stations: %w", err)
}
defer rows.Close()
var stations []Station
stationByID := map[int64]int{} // id → index in stations slice
for rows.Next() {
var s Station
var pwr sql.NullFloat64
if err := rows.Scan(&s.ID, &s.ProfileID, &s.Name, &pwr, &s.SortOrder); err != nil {
return nil, err
}
if pwr.Valid {
v := pwr.Float64
s.TXPower = &v
}
stationByID[s.ID] = len(stations)
stations = append(stations, s)
}
if len(stations) == 0 {
return stations, nil
}
// Build IN-clause placeholders for the second query.
ids := make([]any, 0, len(stations))
placeholders := make([]string, 0, len(stations))
for _, s := range stations {
ids = append(ids, s.ID)
placeholders = append(placeholders, "?")
}
antRows, err := r.db.QueryContext(ctx,
`SELECT id, station_id, name, sort_order
FROM operating_antennas
WHERE station_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY station_id, sort_order, id`, ids...)
if err != nil {
return nil, fmt.Errorf("list antennas: %w", err)
}
// Collect antennas into a flat map keyed by ID first — taking pointers
// into a slice we later append to is unsafe (a re-allocation
// invalidates older pointers, leaving the band loop writing to dead
// memory). We assemble the per-station slices at the very end, once
// everything is collected.
antennasByID := map[int64]*Antenna{}
antennaIDsByStation := map[int64][]int64{}
for antRows.Next() {
a := &Antenna{}
if err := antRows.Scan(&a.ID, &a.StationID, &a.Name, &a.SortOrder); err != nil {
antRows.Close()
return nil, err
}
antennasByID[a.ID] = a
antennaIDsByStation[a.StationID] = append(antennaIDsByStation[a.StationID], a.ID)
}
antRows.Close()
if len(antennasByID) > 0 {
antIDs := make([]any, 0, len(antennasByID))
antPlaceholders := make([]string, 0, len(antennasByID))
for id := range antennasByID {
antIDs = append(antIDs, id)
antPlaceholders = append(antPlaceholders, "?")
}
bandRows, err := r.db.QueryContext(ctx,
`SELECT antenna_id, band, is_default
FROM operating_antenna_bands
WHERE antenna_id IN (`+strings.Join(antPlaceholders, ",")+`)
ORDER BY band`, antIDs...)
if err != nil {
return nil, fmt.Errorf("list bands: %w", err)
}
for bandRows.Next() {
var antID int64
var band string
var isDefault int
if err := bandRows.Scan(&antID, &band, &isDefault); err != nil {
bandRows.Close()
return nil, err
}
if a, ok := antennasByID[antID]; ok {
a.Bands = append(a.Bands, AntennaBand{Band: band, IsDefault: isDefault != 0})
}
}
bandRows.Close()
}
// Now assemble each station's Antennas slice. By the time we do this
// every antenna already has its full band list attached, so no
// downstream pointer is left behind.
for sIdx := range stations {
for _, antID := range antennaIDsByStation[stations[sIdx].ID] {
if a, ok := antennasByID[antID]; ok {
stations[sIdx].Antennas = append(stations[sIdx].Antennas, *a)
}
}
}
return stations, nil
}
// SaveStation upserts a station. Returns the (possibly new) ID.
func (r *Repo) SaveStation(ctx context.Context, s *Station) error {
if strings.TrimSpace(s.Name) == "" {
return fmt.Errorf("station name required")
}
var pwr any
if s.TXPower != nil {
pwr = *s.TXPower
}
if s.ID == 0 {
res, err := r.db.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order)
VALUES(?, ?, ?, ?)`, s.ProfileID, s.Name, pwr, s.SortOrder)
if err != nil {
return fmt.Errorf("insert station: %w", err)
}
id, _ := res.LastInsertId()
s.ID = id
return nil
}
_, err := r.db.ExecContext(ctx,
`UPDATE operating_stations
SET name = ?, tx_pwr = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, s.Name, pwr, s.SortOrder, s.ID)
if err != nil {
return fmt.Errorf("update station: %w", err)
}
return nil
}
// DeleteStation cascades to antennas and bands via FK ON DELETE CASCADE.
func (r *Repo) DeleteStation(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_stations WHERE id = ?`, id)
return err
}
// SaveAntenna upserts an antenna and replaces its band list in one
// transaction. `is_default` is enforced per profile: setting it on one
// antenna clears any other antenna's default for the same band.
func (r *Repo) SaveAntenna(ctx context.Context, a *Antenna) error {
if strings.TrimSpace(a.Name) == "" {
return fmt.Errorf("antenna name required")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if a.ID == 0 {
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order)
VALUES(?, ?, ?)`, a.StationID, a.Name, a.SortOrder)
if err != nil {
return fmt.Errorf("insert antenna: %w", err)
}
id, _ := res.LastInsertId()
a.ID = id
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE operating_antennas
SET name = ?, sort_order = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`, a.Name, a.SortOrder, a.ID); err != nil {
return fmt.Errorf("update antenna: %w", err)
}
}
// Look up profile_id for this antenna's station — needed for the
// "single default per band per profile" constraint.
var profileID int64
if err := tx.QueryRowContext(ctx,
`SELECT s.profile_id FROM operating_stations s WHERE s.id = ?`,
a.StationID).Scan(&profileID); err != nil {
return fmt.Errorf("lookup profile id: %w", err)
}
// Replace band list wholesale — simpler than diffing, fine for the
// small N (a typical antenna covers a handful of bands).
if _, err := tx.ExecContext(ctx,
`DELETE FROM operating_antenna_bands WHERE antenna_id = ?`, a.ID); err != nil {
return fmt.Errorf("clear bands: %w", err)
}
for _, b := range a.Bands {
band := strings.TrimSpace(strings.ToLower(b.Band))
if band == "" {
continue
}
def := 0
if b.IsDefault {
def = 1
}
// Insert this antenna's band entry, then if it's a default
// clear other antennas' default for the same band within
// the same profile.
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default)
VALUES(?, ?, ?)`, a.ID, band, def); err != nil {
return fmt.Errorf("insert band: %w", err)
}
if def == 1 {
if _, err := tx.ExecContext(ctx, `
UPDATE operating_antenna_bands
SET is_default = 0
WHERE band = ?
AND antenna_id != ?
AND antenna_id IN (
SELECT oa.id FROM operating_antennas oa
JOIN operating_stations os ON oa.station_id = os.id
WHERE os.profile_id = ?
)`, band, a.ID, profileID); err != nil {
return fmt.Errorf("clear other defaults: %w", err)
}
}
}
return tx.Commit()
}
// DeleteAntenna cascades to bands via FK ON DELETE CASCADE.
func (r *Repo) DeleteAntenna(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM operating_antennas WHERE id = ?`, id)
return err
}
// BandDefault returns the (station, antenna) flagged default for the given
// band in the given profile. Empty result when nothing matches — callers
// should leave MY_RIG/MY_ANTENNA blank in that case.
func (r *Repo) BandDefault(ctx context.Context, profileID int64, band string) (BandDefault, bool, error) {
band = strings.TrimSpace(strings.ToLower(band))
if band == "" {
return BandDefault{}, false, nil
}
row := r.db.QueryRowContext(ctx, `
SELECT s.id, s.name, s.tx_pwr,
a.id, a.name
FROM operating_antenna_bands ab
JOIN operating_antennas a ON ab.antenna_id = a.id
JOIN operating_stations s ON a.station_id = s.id
WHERE s.profile_id = ? AND ab.band = ? AND ab.is_default = 1
LIMIT 1`, profileID, band)
var (
d BandDefault
pwr sql.NullFloat64
)
if err := row.Scan(&d.StationID, &d.StationName, &pwr,
&d.AntennaID, &d.AntennaName); err != nil {
if err == sql.ErrNoRows {
return BandDefault{}, false, nil
}
return BandDefault{}, false, err
}
if pwr.Valid {
v := pwr.Float64
d.TXPower = &v
}
return d, true, nil
}
+63 -17
View File
@@ -257,6 +257,7 @@ func decodeExtras(s string) map[string]string {
// Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
if q.Callsign == "" {
return 0, fmt.Errorf("empty callsign")
}
@@ -293,6 +294,7 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
var inserted int64
for _, q := range qsos {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
if q.Callsign == "" {
continue
}
@@ -322,6 +324,7 @@ func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 {
return fmt.Errorf("missing id")
}
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))
if q.Callsign == "" {
return fmt.Errorf("empty callsign")
}
@@ -412,8 +415,9 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
q := `SELECT ` + selectCols + ` FROM qso WHERE 1=1`
args := []any{}
if f.Callsign != "" {
// Contains-match so a search for "XYZ" finds F4XYZ, F4XYZ/P, etc.
q += " AND callsign LIKE ?"
args = append(args, f.Callsign+"%")
args = append(args, "%"+f.Callsign+"%")
}
if f.Band != "" {
q += " AND band = ?"
@@ -428,8 +432,12 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
args = append(args, f.StationCallsign)
}
q += " ORDER BY qso_date DESC, id DESC"
if f.Limit <= 0 || f.Limit > 1000 {
f.Limit = 200
if f.Limit <= 0 {
f.Limit = 500
}
// Hard upper bound: 1M is enough to fit any realistic personal log.
if f.Limit > 1_000_000 {
f.Limit = 1_000_000
}
q += " LIMIT ? OFFSET ?"
args = append(args, f.Limit, f.Offset)
@@ -549,14 +557,14 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// ---- Per-callsign stats ----
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
`SELECT COUNT(*) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&wb.Count); err != nil {
return wb, fmt.Errorf("count worked: %w", err)
}
if wb.Count > 0 {
rows, err := r.db.QueryContext(ctx, `
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd,
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd
FROM qso WHERE callsign = ?
FROM qso WHERE upper(trim(callsign)) = ?
ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries)
if err != nil {
@@ -569,14 +577,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
var (
e WorkedEntry
dateStr string
band, mode sql.NullString
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
)
if err := rows.Scan(&e.ID, &dateStr, &e.Band, &e.Mode,
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
rows.Close()
return wb, fmt.Errorf("scan worked: %w", err)
}
e.QSODate = parseTimeLoose(dateStr)
e.Band = band.String
e.Mode = mode.String
e.RSTSent = rstS.String
e.RSTRcvd = rstR.String
e.QSLSent = qslS.String
@@ -584,9 +595,15 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
e.LOTWSent = lotwS.String
e.LOTWRcvd = lotwR.String
wb.Entries = append(wb.Entries, e)
bandsSet[e.Band] = struct{}{}
modesSet[e.Mode] = struct{}{}
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
if e.Band != "" {
bandsSet[e.Band] = struct{}{}
}
if e.Mode != "" {
modesSet[e.Mode] = struct{}{}
}
if e.Band != "" && e.Mode != "" {
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode}
}
if wb.Last.IsZero() {
wb.Last = e.QSODate
}
@@ -597,7 +614,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
if wb.Count > maxWorkedEntries {
var firstStr sql.NullString
_ = r.db.QueryRowContext(ctx,
`SELECT MIN(qso_date) FROM qso WHERE callsign = ?`, wb.Callsign).Scan(&firstStr)
`SELECT MIN(qso_date) FROM qso WHERE upper(trim(callsign)) = ?`, wb.Callsign).Scan(&firstStr)
if firstStr.Valid {
wb.First = parseTimeLoose(firstStr.String)
}
@@ -622,7 +639,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
var d sql.NullInt64
_ = r.db.QueryRowContext(ctx, `
SELECT dxcc FROM qso
WHERE callsign = ? AND dxcc IS NOT NULL
WHERE upper(trim(callsign)) = ? AND dxcc IS NOT NULL
ORDER BY qso_date DESC LIMIT 1`, wb.Callsign).Scan(&d)
if d.Valid {
dxcc = int(d.Int64)
@@ -656,15 +673,17 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
}
if err := r.collectDistinct(ctx, &wb.DXCCBands,
`SELECT DISTINCT band FROM qso WHERE dxcc = ?`, dxcc); err != nil {
`SELECT DISTINCT band FROM qso WHERE dxcc = ? AND band IS NOT NULL AND band != ''`, dxcc); err != nil {
return wb, err
}
if err := r.collectDistinct(ctx, &wb.DXCCModes,
`SELECT DISTINCT mode FROM qso WHERE dxcc = ?`, dxcc); err != nil {
`SELECT DISTINCT mode FROM qso WHERE dxcc = ? AND mode IS NOT NULL AND mode != ''`, dxcc); err != nil {
return wb, err
}
bmRows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?`, dxcc)
`SELECT DISTINCT band, mode FROM qso WHERE dxcc = ?
AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`, dxcc)
if err != nil {
return wb, fmt.Errorf("dxcc band_modes: %w", err)
}
@@ -684,15 +703,21 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
// One pass over every distinct (band, mode) in the DXCC, aggregating
// "did this call work it?" and "was anything confirmed?" via MAX.
// Status precedence: call_c > call_w > dxcc_c > dxcc_w.
// Filter NULL/empty band+mode rows — they'd create a NULL group key
// that Scan into *string can't handle and would error out the whole
// WorkedBefore call, blanking the matrix in the UI.
statusRows, err := r.db.QueryContext(ctx, `
SELECT band, mode,
MAX(CASE WHEN callsign = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN callsign = ?
MAX(CASE WHEN upper(trim(callsign)) = ? THEN 1 ELSE 0 END),
MAX(CASE WHEN upper(trim(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 = ?
FROM qso
WHERE dxcc = ?
AND band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''
GROUP BY band, mode`, wb.Callsign, wb.Callsign, dxcc)
if err != nil {
return wb, fmt.Errorf("band status: %w", err)
@@ -886,6 +911,27 @@ func (r *Repo) EntitySlotMap(ctx context.Context, resolveEntity func(callsign st
return out, rows.Err()
}
// WorkedCallsigns returns the set of every callsign ever logged (uppercased).
// One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 1024)
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
out[c] = struct{}{}
}
return out, rows.Err()
}
// Count returns the total number of QSOs in the database.
func (r *Repo) Count(ctx context.Context) (int64, error) {
var n int64