rigs completed
This commit is contained in:
+50
-6
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user