This commit is contained in:
2026-06-15 23:45:14 +02:00
parent 29fd832bcd
commit 22e3bb4a18
32 changed files with 2531 additions and 362 deletions
+19
View File
@@ -120,6 +120,25 @@ func SingleRecordADIF(q qso.QSO) string {
return b.String()
}
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
func BatchRecordsADIF(records []string) string {
var b strings.Builder
now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
len(adifVersion), adifVersion, now)
for _, r := range records {
r = strings.TrimRight(r, "\r\n")
if r == "" {
continue
}
b.WriteString(r)
b.WriteString("\n")
}
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
+18
View File
@@ -5,11 +5,26 @@ package audio
import (
"encoding/binary"
"fmt"
"runtime/debug"
"strings"
"sync"
"time"
)
// LogSink receives audio-subsystem diagnostics (set to applog.Printf at startup).
// Defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// recoverGoroutine turns a panic in a long-running audio goroutine into a logged
// event with a stack trace instead of a silent process-killing crash. (It can't
// catch a hard Windows access violation from the WASAPI layer — those are fatal
// — but it catches any Go-level panic in capture/mix.)
func recoverGoroutine(what string) {
if r := recover(); r != nil {
LogSink("audio: PANIC in %s: %v\n%s", what, r, debug.Stack())
}
}
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
@@ -108,6 +123,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (radio)")
_ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -119,6 +135,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder capture (mic)")
_ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
@@ -132,6 +149,7 @@ func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer recoverGoroutine("recorder mixer")
t := time.NewTicker(40 * time.Millisecond)
defer t.Stop()
for {
+67 -1
View File
@@ -414,7 +414,73 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
}
}
rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0)
found := candidates(&d, re, q, rl, len(metas) > 0)
// Merge operator-assigned references (manual override). These let the
// operator tag a QSO for an award whose field/description matching can't
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
// province NAME, so a contact whose address doesn't spell it out needs the
// province picked by hand. For a predefined award the override is still
// validated against its reference list.
if man := manualRefs(q, d.Code); len(man) > 0 {
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
}
return found
}
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
// operator-assigned award references as "CODE@REF;CODE@REF" (REF may be a
// comma list). Honoured by MatchQSO regardless of how the award matches.
const ManualRefsKey = "APP_OPSLOG_AWARDREFS"
// manualRefs returns the reference codes the operator assigned to award `code`
// on this QSO (from the ManualRefsKey extra).
func manualRefs(q *qso.QSO, code string) []string {
if q == nil || q.Extras == nil {
return nil
}
raw := strings.TrimSpace(q.Extras[ManualRefsKey])
if raw == "" {
return nil
}
code = strings.ToUpper(strings.TrimSpace(code))
var out []string
for _, entry := range strings.Split(raw, ";") {
entry = strings.TrimSpace(entry)
at := strings.IndexByte(entry, '@')
if at <= 0 || !strings.EqualFold(strings.TrimSpace(entry[:at]), code) {
continue
}
for _, r := range strings.FieldsFunc(entry[at+1:], func(r rune) bool { return r == ',' }) {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
}
return out
}
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
// When the award is predefined, only references present and valid in its list
// are kept (so a typo can't invent a reference).
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
seen := map[string]struct{}{}
for _, c := range found {
seen[normalizeRef(c)] = struct{}{}
}
for _, c := range manual {
c = normalizeRef(c)
if _, dup := seen[c]; dup {
continue
}
if predefined {
if m, ok := rl.byCode[c]; !ok || !m.Valid {
continue
}
}
seen[c] = struct{}{}
found = append(found, c)
}
return found
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
+20 -5
View File
@@ -57,6 +57,7 @@ type RigState struct {
// Manager owns the active backend and runs the polling loop.
type Manager struct {
mu sync.RWMutex
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
state RigState
emit func(RigState)
backend Backend
@@ -115,7 +116,13 @@ func (m *Manager) State() RigState {
// state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) {
m.Stop()
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
@@ -134,6 +141,18 @@ func (m *Manager) Start(b Backend) {
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() {
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
m.mu.Lock()
stop := m.stopCh
done := m.doneCh
@@ -148,10 +167,6 @@ func (m *Manager) Stop() {
if done != nil {
<-done
}
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
+99 -42
View File
@@ -3,11 +3,21 @@ package cat
import (
"fmt"
"strings"
"time"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig Split is an enum, not a boolean: PM_SPLITON vs PM_SPLITOFF — both
// non-zero, so it must be compared to PM_SPLITON (testing "!= 0" reads OFF as
// split). Values confirmed empirically from real rigs (FT-710, SmartSDR):
// split ON = 0x8000, split OFF = 0x10000.
const (
pmSplitOn = 0x8000 // PM_SPLITON
pmSplitOff = 0x10000 // PM_SPLITOFF
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
@@ -21,6 +31,15 @@ type OmniRig struct {
omnirig *ole.IDispatch
rig *ole.IDispatch
lastSig string // last logged Split/VFO signature — only log on change
// lastSetFreq is the frequency most recently COMMANDED via SetFrequency.
// SetMode uses it to pick USB vs LSB for "SSB" instead of reading OmniRig's
// async Freq property, which still reports the OLD band for a poll or two
// after a QSY — that lag is why a clicked spot needed a second click to fix
// the sideband (freq moved, but mode read the old band → wrong sideband).
lastSetFreq int64
lastSetFreqAt time.Time
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
@@ -107,13 +126,19 @@ func (o *OmniRig) ReadState() (RigState, error) {
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
rawVfo := int64(0)
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
rawVfo = vfoVar.Val
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read both VFO frequencies separately so we can expose split TX/RX.
// Fall back to generic Freq if the rig only exposes the merged property.
freqA, freqB := int64(0), int64(0)
// Read the active/displayed frequency (generic Freq) AND both VFOs. The
// generic Freq is what the rig is operating on — the reliable source for the
// main/TX frequency. FreqA/FreqB are only needed to expose a genuine split.
freqMain, freqA, freqB := int64(0), int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freqMain = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
@@ -121,38 +146,58 @@ func (o *OmniRig) ReadState() (RigState, error) {
freqB = v.Val
}
// Split detection: trust the explicit Split property when it's set,
// BUT only call it a real split if both VFO frequencies are non-zero
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
// slice model doesn't map to VFO A/B — that would yield a useless
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
// Split is an enum (PM_SPLITON / PM_SPLITOFF) — both non-zero, so it must be
// compared to PM_SPLITON, not "!= 0".
splitRaw := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
splitRaw = v.Val
}
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA":
s.FreqHz = freqA // TX
s.RxFreqHz = freqB // RX
case "B", "BB":
s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown
s.FreqHz = freqA
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
// normal operation stays quiet but toggling split on the radio is captured —
// needed to pin down this rig's PM_SPLITON value.
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
o.lastSig = sig
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
return v.Val
}
return -1
}())
}
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
// A genuine split: the rig explicitly flags PM_SPLITON, the two VFOs are
// distinct and non-zero, AND they're in the same band. The same-band test
// kills the common false positive where VFO B just holds a leftover from
// another band (a "28 MHz / 7 MHz split" is nonsensical), which on the
// FT-710 / TS-570 otherwise froze the main/TX freq on the wrong VFO.
genuineSplit := splitRaw == pmSplitOn &&
freqA != 0 && freqB != 0 && freqA != freqB &&
BandFromHz(freqA) == BandFromHz(freqB)
if genuineSplit {
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// ADIF: FreqHz = TX, RxFreqHz = RX.
s.Split = true
switch s.Vfo {
case "BA":
s.FreqHz, s.RxFreqHz = freqA, freqB // TX A, RX B
default: // "AB" and the usual "TX on the other VFO" case
s.FreqHz, s.RxFreqHz = freqB, freqA // TX B, RX A
}
} else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0.
s.Split = false
s.RxFreqHz = 0
s.FreqHz = freqMain
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB
} else {
s.FreqHz = freqA
}
}
}
return s, nil
@@ -169,6 +214,10 @@ func (o *OmniRig) SetFrequency(hz int64) error {
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Remember the commanded frequency so a mode change moments later (a clicked
// spot sets freq then mode) picks the sideband from the TARGET band, not the
// not-yet-updated OmniRig Freq property.
o.lastSetFreq, o.lastSetFreqAt = hz, time.Now()
// Log the rig's writable-params, status and VFO state up front so a
// friend's session shows exactly what OmniRig reports for their rig.
@@ -274,9 +323,15 @@ func (o *OmniRig) SetMode(mode string) error {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Read current freq to decide USB vs LSB.
// Decide USB vs LSB from the frequency. Prefer the freq we just COMMANDED
// (a clicked spot sets freq then mode ~150ms later): OmniRig's Freq
// property still reports the OLD band for a poll or two after a QSY, so
// reading it here picked the wrong sideband and the user had to click a
// second time. Fall back to the live read for a standalone mode change.
var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
if o.lastSetFreq > 0 && time.Since(o.lastSetFreqAt) < 5*time.Second {
freq = o.lastSetFreq
} else if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
@@ -396,20 +451,22 @@ func omniRigMode(m int64) string {
return ""
}
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
// documented PM_VFO* constants.
func omniRigVfo(v int64) string {
switch {
case v&1024 != 0:
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
case v&0x40 != 0: // PM_VFOAA
return "AA"
case v&128 != 0:
case v&0x80 != 0: // PM_VFOAB
return "AB"
case v&256 != 0:
case v&0x100 != 0: // PM_VFOBA
return "BA"
case v&512 != 0:
case v&0x200 != 0: // PM_VFOBB
return "BB"
case v&0x400 != 0: // PM_VFOA
return "A"
case v&0x800 != 0: // PM_VFOB
return "B"
}
return ""
}
+52 -15
View File
@@ -29,8 +29,12 @@ func (c MySQLConfig) dsn() string {
port = 3306
}
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
// Unicode (names, comments…).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4",
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
// or wrong-port server fails fast with a real error instead of hanging
// startup (which would surface only as "db not initialized"); read/write
// timeouts cap a stuck statement (generous, so normal migrations/imports
// never trip them).
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
c.User, c.Password, c.Host, port, c.Database)
}
@@ -50,8 +54,12 @@ func validDBIdent(s string) bool {
// PingMySQL verifies a shared-database connection and creates the logbook
// database if it doesn't exist yet. It connects at server level first (no
// database selected) so a not-yet-created DB isn't an error, then runs
// CREATE DATABASE IF NOT EXISTS. Backs the settings "Test connection" button.
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
// database is actually usable. A restricted user (common on shared hosting)
// may lack the CREATE DATABASE privilege but still have full rights on a
// pre-created database — so a denied CREATE is not fatal as long as the
// database already exists and we can connect to it. Backs the "Test
// connection" button.
func PingMySQL(c MySQLConfig) error {
if strings.TrimSpace(c.Host) == "" {
return fmt.Errorf("host is required")
@@ -63,17 +71,37 @@ func PingMySQL(c MySQLConfig) error {
return fmt.Errorf("open mysql: %w", err)
}
defer conn.Close()
conn.SetConnMaxLifetime(5 * time.Second)
conn.SetConnMaxLifetime(15 * time.Second)
if err := conn.Ping(); err != nil {
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
}
if name := strings.TrimSpace(c.Database); name != "" {
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
name := strings.TrimSpace(c.Database)
if name == "" {
return nil
}
if !validDBIdent(name) {
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
}
createErr := error(nil)
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
if !isAccessDenied(err) {
return fmt.Errorf("create database %q: %w", name, err)
}
createErr = err // remember it in case the DB also turns out to be unreachable
}
// Confirm the database is usable (it may have been pre-created by an admin
// even though this user can't CREATE one).
dbConn, err := sql.Open("mysql", c.dsn())
if err != nil {
return fmt.Errorf("open mysql db: %w", err)
}
defer dbConn.Close()
dbConn.SetConnMaxLifetime(15 * time.Second)
if err := dbConn.Ping(); err != nil {
if createErr != nil {
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
}
return fmt.Errorf("connect to database %q: %w", name, err)
}
return nil
}
@@ -81,6 +109,13 @@ func PingMySQL(c MySQLConfig) error {
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
// dialect; run through the dialect translator for MySQL.
const schemaMigrationsDDL = `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'))
)`
// Dialect names the backend opened at startup. Repos consult it for the few
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
@@ -138,13 +173,15 @@ func Open(path string) (*sql.DB, error) {
// (no external dependency). translate, when non-nil, rewrites each statement
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
func migrate(conn *sql.DB, translate func(string) string) error {
// A non-nil translator means this is the MySQL connection (use the
// per-statement, FK-aware path); nil means a SQLite connection. This is
// determined by the caller's argument, NOT the global Dialect, so the
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
mysqlPath := translate != nil
if translate == nil {
translate = func(s string) string { return s }
}
if _, err := conn.Exec(translate(`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 {
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
@@ -182,7 +219,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
// "duplicate column". Instead apply statement-by-statement and tolerate
// "already exists" errors, making migrations idempotent (and self-healing
// for a previously half-applied database).
if IsMySQL() {
if mysqlPath {
if err := applyMySQLMigration(conn, sqlText); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
@@ -0,0 +1,6 @@
-- Per-profile logbook database. Each profile can target its own logbook:
-- the local SQLite file, or a specific shared MySQL database. Switching the
-- active profile switches the logbook accordingly. Stored as a small JSON
-- document {backend, host, port, user, password, database}; empty = inherit
-- the default (local SQLite).
ALTER TABLE station_profiles ADD COLUMN db_config TEXT NOT NULL DEFAULT '';
+223 -6
View File
@@ -37,6 +37,11 @@ var (
reBareInteger = regexp.MustCompile(`\bINTEGER\b`)
reColText = regexp.MustCompile(`(\w+)\s+TEXT\b`)
reKeyColumn = regexp.MustCompile(`(?m)^(\s*)key\b`)
// SQLite quotes identifiers with double quotes — e.g. a table renamed by
// ALTER … RENAME TO ends up as CREATE TABLE "name" in sqlite_master. MySQL
// uses backticks. Our schema has no double-quoted string literals, so it's
// safe to convert every "ident" to `ident`.
reDoubleQuoteIdent = regexp.MustCompile(`"([^"\n]*)"`)
)
// MySQL's InnoDB row-size limit is 65535 bytes (excluding off-page TEXT/BLOB).
@@ -71,6 +76,9 @@ var longTextColumns = map[string]string{
// file) into the MySQL dialect. See the package note above for the rules.
func mysqlDDL(stmt string) string {
s := stmt
// SQLite double-quoted identifiers → MySQL backticks (renamed tables in the
// baseline dump, etc.).
s = reDoubleQuoteIdent.ReplaceAllString(s, "`$1`")
// MySQL has no IF NOT EXISTS for CREATE INDEX (migrations run once anyway).
s = strings.ReplaceAll(s, "CREATE INDEX IF NOT EXISTS", "CREATE INDEX")
// Auto-increment primary keys. Both the AUTOINCREMENT form and the bare
@@ -144,7 +152,11 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
conn.SetMaxOpenConns(50)
conn.SetMaxIdleConns(10)
conn.SetConnMaxIdleTime(90 * time.Second)
conn.SetConnMaxLifetime(5 * time.Minute)
// No max lifetime: a slow server's first migration can run for minutes on a
// single connection, and reaping it mid-migration drops the selected database
// (surfacing as "Unknown database"). Idle connections are still recycled
// after 90s, and the driver retries stale pooled connections.
conn.SetConnMaxLifetime(0)
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("connect to %s: %w", name, err)
@@ -152,7 +164,22 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
// Set the dialect before migrating so the runner takes the MySQL path
// (per-statement, idempotent) rather than the SQLite transaction path.
Dialect = "mysql"
if err := migrate(conn, mysqlDDL); err != nil {
fresh, err := isFreshMySQL(conn)
if err != nil {
_ = conn.Close()
Dialect = "sqlite"
return nil, fmt.Errorf("inspect database: %w", err)
}
if fresh {
// Empty database: build the whole final schema in one pass (one CREATE
// per table, no ALTERs) — far faster than replaying 21 migrations,
// especially on a server with slow DDL.
err = applyMySQLBaseline(conn)
} else {
// Existing database: apply only the migrations it's missing.
err = migrate(conn, mysqlDDL)
}
if err != nil {
_ = conn.Close()
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
@@ -160,6 +187,116 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
return conn, nil
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.
func isFreshMySQL(conn *sql.DB) (bool, error) {
var name string
err := conn.QueryRow("SHOW TABLES LIKE 'qso'").Scan(&name)
if err == sql.ErrNoRows {
return true, nil
}
if err != nil {
return false, err
}
return false, nil
}
// applyMySQLBaseline creates the entire current schema on a fresh MySQL database
// in a single pass, then records every migration as already applied. The schema
// is derived from the migrations themselves (replayed on a throwaway in-memory
// SQLite, whose sqlite_master holds each table's FINAL CREATE statement with all
// ALTER-added columns folded in) and translated to MySQL — so there's no second
// schema to maintain and the two backends can't drift. Future migrations apply
// incrementally on top.
func applyMySQLBaseline(conn *sql.DB) error {
mem, err := sql.Open("sqlite", "file:opslog_baseline?mode=memory&cache=shared")
if err != nil {
return fmt.Errorf("open baseline sqlite: %w", err)
}
defer mem.Close()
if err := migrate(mem, nil); err != nil {
return fmt.Errorf("build baseline schema: %w", err)
}
var migNames []string
mrows, err := mem.Query("SELECT name FROM schema_migrations ORDER BY name")
if err != nil {
return fmt.Errorf("read baseline migrations: %w", err)
}
for mrows.Next() {
var n string
if err := mrows.Scan(&n); err != nil {
mrows.Close()
return err
}
migNames = append(migNames, n)
}
mrows.Close()
var tables, indexes []string
srows, err := mem.Query("SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'")
if err != nil {
return fmt.Errorf("read baseline schema: %w", err)
}
for srows.Next() {
var typ, s string
if err := srows.Scan(&typ, &s); err != nil {
srows.Close()
return err
}
switch typ {
case "table":
tables = append(tables, s)
case "index":
indexes = append(indexes, s)
}
}
srows.Close()
// Apply on one connection with FK checks off so table order doesn't matter.
ctx := context.Background()
c, err := conn.Conn(ctx)
if err != nil {
return fmt.Errorf("acquire conn: %w", err)
}
defer func() {
_, _ = c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=1")
_ = c.Close()
}()
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
if _, err := c.ExecContext(ctx, mysqlDDL(schemaMigrationsDDL)); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, tb := range tables {
if _, err := c.ExecContext(ctx, mysqlDDL(tb)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline table: %w", err)
}
}
for _, ix := range indexes {
if _, err := c.ExecContext(ctx, mysqlDDL(ix)); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("baseline index: %w", err)
}
}
for _, n := range migNames {
if _, err := conn.Exec("INSERT INTO schema_migrations(name) VALUES(?)", n); err != nil {
if isIgnorableDDLError(err) {
continue
}
return fmt.Errorf("record migration %s: %w", n, err)
}
}
return nil
}
// applyMySQLMigration executes a translated migration one statement at a time.
// MySQL has no multi-statement Exec without a special flag and auto-commits DDL,
// so running statements individually (and tolerating "already exists" errors)
@@ -182,9 +319,19 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
if _, err := c.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS=0"); err != nil {
return fmt.Errorf("disable fk checks: %w", err)
}
for _, stmt := range splitStatements(sqlText) {
for _, stmt := range coalesceAddColumns(splitStatements(sqlText)) {
if _, err := c.ExecContext(ctx, stmt); err != nil {
if isIgnorableDDLError(err) {
// A coalesced multi-column ALTER fails wholesale if even one
// column already exists (partial prior apply). Re-run it column
// by column so the missing ones still get added.
if cols := decomposeAddColumns(stmt); len(cols) > 1 {
for _, one := range cols {
if _, e := c.ExecContext(ctx, one); e != nil && !isIgnorableDDLError(e) {
return fmt.Errorf("statement %q: %w", firstLine(one), e)
}
}
}
continue
}
return fmt.Errorf("statement %q: %w", firstLine(stmt), err)
@@ -193,13 +340,65 @@ func applyMySQLMigration(conn *sql.DB, sqlText string) error {
return nil
}
var reAddColumn = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+COLUMN\s+(.+)$`)
// coalesceAddColumns merges consecutive "ALTER TABLE t ADD COLUMN …" statements
// on the same table into a single multi-column ALTER. SQLite needs one ALTER per
// column, but each is a full table operation in MySQL — and on a slow server
// that's seconds apiece, turning a ~50-column migration into minutes (long
// enough to trip connection lifetimes and stall startup). One combined ALTER is
// a single table rebuild instead of fifty.
func coalesceAddColumns(stmts []string) []string {
out := make([]string, 0, len(stmts))
for i := 0; i < len(stmts); {
m := reAddColumn.FindStringSubmatch(stmts[i])
if m == nil {
out = append(out, stmts[i])
i++
continue
}
table := m[1]
cols := []string{strings.TrimSpace(m[2])}
j := i + 1
for j < len(stmts) {
m2 := reAddColumn.FindStringSubmatch(stmts[j])
if m2 == nil || m2[1] != table {
break
}
cols = append(cols, strings.TrimSpace(m2[2]))
j++
}
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.Join(cols, ", ADD COLUMN "))
i = j
}
return out
}
// decomposeAddColumns splits a (possibly coalesced) ADD COLUMN ALTER back into
// one ALTER per column, for the idempotent fallback path.
func decomposeAddColumns(stmt string) []string {
m := reAddColumn.FindStringSubmatch(stmt)
if m == nil {
return nil
}
table := m[1]
var out []string
for _, col := range strings.Split(m[2], ", ADD COLUMN ") {
out = append(out, "ALTER TABLE "+table+" ADD COLUMN "+strings.TrimSpace(col))
}
return out
}
// splitStatements breaks a migration file into individual SQL statements,
// dropping full-line comments and blank fragments. Our migrations never embed
// a ';' inside a string literal, so a simple split is safe.
// dropping comments (full-line and inline "-- …") and blank fragments. Our
// migrations never embed a ';' or '--' inside a string literal, so this is safe.
func splitStatements(sqlText string) []string {
var b strings.Builder
for _, line := range strings.Split(sqlText, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "--") {
if i := strings.Index(line, "--"); i >= 0 {
line = line[:i] // strip inline / full-line comment
}
if strings.TrimSpace(line) == "" {
continue
}
b.WriteString(line)
@@ -222,6 +421,24 @@ func firstLine(s string) string {
return s
}
// isAccessDenied reports whether a MySQL error is a privilege denial — e.g. a
// restricted user trying to CREATE DATABASE. Such users can still operate on a
// pre-existing database they've been granted, so the caller treats this as
// non-fatal and verifies database access separately.
func isAccessDenied(err error) bool {
var me *mysqldriver.MySQLError
if !errors.As(err, &me) {
return false
}
switch me.Number {
case 1044, // ER_DBACCESS_DENIED_ERROR — access denied to database
1045, // ER_ACCESS_DENIED_ERROR — access denied for user
1142: // ER_TABLEACCESS_DENIED_ERROR — command denied to user
return true
}
return false
}
// isIgnorableDDLError reports whether a MySQL error means the object the
// statement creates/drops is already in the intended state — safe to skip when
// re-applying a forward-only migration.
+76 -2
View File
@@ -1,19 +1,26 @@
package extsvc
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -67,6 +74,73 @@ func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig,
return res, nil
}
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
}
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
+53
View File
@@ -58,6 +58,59 @@ type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// CopyProfile duplicates the entire stations→antennas→bands tree from one
// profile to another (used when a profile is duplicated). New ids are assigned;
// the band defaults and ordering are preserved.
func (r *Repo) CopyProfile(ctx context.Context, srcProfileID, dstProfileID int64) error {
tree, err := r.ListTree(ctx, srcProfileID)
if err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
now := db.NowISO()
for _, st := range tree {
var pwr any
if st.TXPower != nil {
pwr = *st.TXPower
}
res, err := tx.ExecContext(ctx,
`INSERT INTO operating_stations(profile_id, name, tx_pwr, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?)`, dstProfileID, st.Name, pwr, st.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy station: %w", err)
}
newStationID, _ := res.LastInsertId()
for _, ant := range st.Antennas {
ares, err := tx.ExecContext(ctx,
`INSERT INTO operating_antennas(station_id, name, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?)`, newStationID, ant.Name, ant.SortOrder, now, now)
if err != nil {
return fmt.Errorf("copy antenna: %w", err)
}
newAntID, _ := ares.LastInsertId()
for _, b := range ant.Bands {
if _, err := tx.ExecContext(ctx,
`INSERT INTO operating_antenna_bands(antenna_id, band, is_default) VALUES(?,?,?)`,
newAntID, b.Band, boolToInt(b.IsDefault)); err != nil {
return fmt.Errorf("copy band: %w", err)
}
}
}
}
return tx.Commit()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// 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.
+41 -2
View File
@@ -11,10 +11,23 @@ package profile
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
)
// ProfileDB is a profile's logbook database target. Backend "" or "sqlite" =
// the local SQLite file; "mysql" = the shared MySQL server described by the
// rest. Switching the active profile switches the live logbook to this.
type ProfileDB struct {
Backend string `json:"backend"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
// Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct {
@@ -43,6 +56,7 @@ type Profile struct {
TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
DB ProfileDB `json:"db"` // per-profile logbook target (empty backend = local SQLite)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -56,7 +70,7 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, op_name, owner_callsign, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, my_dxcc, my_cqz, my_ituz, my_lat, my_lon, tx_pwr,
is_active, sort_order, created_at, updated_at`
is_active, sort_order, db_config, created_at, updated_at`
// List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
@@ -136,6 +150,20 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
return err
}
// SetDB updates only a profile's logbook database target, leaving the rest of
// the profile untouched (the general Save deliberately doesn't write db_config).
func (r *Repo) SetDB(ctx context.Context, id int64, cfg ProfileDB) error {
b, err := json.Marshal(cfg)
if err != nil {
return err
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
_, err = r.db.ExecContext(ctx,
`UPDATE station_profiles SET db_config = ?, updated_at = ? WHERE id = ?`,
string(b), now, id)
return err
}
// SetActive atomically switches the active profile. Clears the flag on all
// rows first to keep the "only one active" invariant from the schema doc.
func (r *Repo) SetActive(ctx context.Context, id int64) error {
@@ -202,6 +230,7 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err != nil {
return Profile{}, err
}
dbCfg := src.DB // Save deliberately doesn't write db_config — copy it after.
src.ID = 0
src.Name = newName
src.IsActive = false
@@ -209,6 +238,12 @@ func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Prof
if err := r.Save(ctx, &src); err != nil {
return Profile{}, err
}
if dbCfg.Backend != "" {
if err := r.SetDB(ctx, src.ID, dbCfg); err != nil {
return Profile{}, err
}
src.DB = dbCfg
}
return src, nil
}
@@ -227,15 +262,19 @@ func scan(row scannable) (Profile, error) {
myDXCC, myCQZ, myITUZ sql.NullInt64
myLat, myLon, txPwr sql.NullFloat64
isActive, sortOrder int
dbConfig sql.NullString
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &opName, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &txPwr,
&isActive, &sortOrder, &createdAt, &updatedAt)
&isActive, &sortOrder, &dbConfig, &createdAt, &updatedAt)
if err != nil {
return p, err
}
if s := dbConfig.String; s != "" {
_ = json.Unmarshal([]byte(s), &p.DB)
}
p.Callsign = callsign.String
p.Operator = operator.String
p.OpName = opName.String
+113
View File
@@ -454,6 +454,19 @@ func (r *Repo) AddBatch(ctx context.Context, qsos []QSO) (int64, error) {
return inserted, nil
}
// Revision returns a cheap fingerprint of the logbook — row count and the
// highest id — so a client can poll for changes made by OTHER instances on a
// shared MySQL logbook and refresh when it differs. Inserts bump the max id;
// deletes change the count.
func (r *Repo) Revision(ctx context.Context) (string, error) {
var count, maxID int64
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(MAX(id), 0) FROM qso`).Scan(&count, &maxID); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", count, maxID), nil
}
// GetByID fetches a single QSO by primary key.
func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
row := r.db.QueryRowContext(ctx,
@@ -1308,6 +1321,106 @@ func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
return rows.Err()
}
// awardCols is the column set the award engine can read — every field reachable
// from refField/inScope/confirmed in internal/award plus enrichQSOForAwards:
// the reference fields (dxcc, zones, continent, country, state, grid/vucc, iota,
// sota, pota, wwff via extras_json, and the free-text fields name/qth/address/
// comment/notes a custom award may key on), band/mode/qso_date for scoping,
// freq_hz for band recovery, and the qsl/lotw/eqsl rcvd confirmation flags.
// This drops ~125 unused columns so award computation over a remote MySQL
// backend ships a fraction of each row instead of the whole record.
//
// IMPORTANT: when a new award references a QSO field not listed here, add the
// column to this list AND populate it in scanAwardQSO below, or that award will
// silently see an empty value during stats/computation.
const awardCols = `id, callsign, qso_date, band, freq_hz, mode, ` +
`grid, vucc_grids, country, state, cont, cqz, ituz, dxcc, iota, sota_ref, pota_ref, ` +
`name, qth, address, comment, notes, ` +
`qsl_rcvd, lotw_rcvd, eqsl_rcvd, extras_json`
// IterateForAwards streams a lightweight projection of every QSO — only the
// fields award computation reads (see awardCols). All other QSO fields are left
// zero. Use IterateAll when the full record is needed. Ordered by date/id like
// IterateAll for deterministic results.
func (r *Repo) IterateForAwards(ctx context.Context, fn func(QSO) error) error {
rows, err := r.db.QueryContext(ctx,
`SELECT `+awardCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
if err != nil {
return fmt.Errorf("query qso (awards): %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanAwardQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// scanAwardQSO reads one row produced by awardCols into a QSO, populating only
// the award-relevant fields. Column order MUST match awardCols.
func scanAwardQSO(s scanner) (QSO, error) {
var q QSO
var (
qsoDateStr string
freqHz sql.NullInt64
grid, vucc, country, state sql.NullString
cont, iotaRef, sota, pota sql.NullString
dxcc, cqz, ituz sql.NullInt64
name, qth, address sql.NullString
comment, notes sql.NullString
qslRcvd, lotwRcvd, eqslRcvd sql.NullString
extrasJSON sql.NullString
)
if err := s.Scan(
&q.ID, &q.Callsign, &qsoDateStr, &q.Band, &freqHz, &q.Mode,
&grid, &vucc, &country, &state, &cont, &cqz, &ituz, &dxcc, &iotaRef, &sota, &pota,
&name, &qth, &address, &comment, &notes,
&qslRcvd, &lotwRcvd, &eqslRcvd, &extrasJSON,
); err != nil {
return QSO{}, fmt.Errorf("scan qso (awards): %w", err)
}
q.QSODate = parseTimeLoose(qsoDateStr)
if freqHz.Valid {
v := freqHz.Int64
q.FreqHz = &v
}
q.Grid = grid.String
q.VUCCGrids = vucc.String
q.Country = country.String
q.State = state.String
q.Continent = cont.String
if cqz.Valid {
v := int(cqz.Int64)
q.CQZ = &v
}
if ituz.Valid {
v := int(ituz.Int64)
q.ITUZ = &v
}
if dxcc.Valid {
v := int(dxcc.Int64)
q.DXCC = &v
}
q.IOTA = iotaRef.String
q.SOTARef = sota.String
q.POTARef = pota.String
q.Name = name.String
q.QTH = qth.String
q.Address = address.String
q.Comment = comment.String
q.Notes = notes.String
q.QSLRcvd = qslRcvd.String
q.LOTWRcvd = lotwRcvd.String
q.EQSLRcvd = eqslRcvd.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}
// EntitySlot bundles every (band, mode) tuple ever worked for a given
// DXCC entity name. Used by the cluster spot colouring code to decide
// NEW / NEW SLOT / WORKED in constant time after one batched query.
+87 -5
View File
@@ -11,6 +11,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"sync"
"hamlog/internal/db"
@@ -29,6 +30,11 @@ type Store struct {
cipher Cipher // non-nil when secrets are unlocked
sensitive func(key string) bool // which keys are encrypted at rest
// prefix scopes every key to the active profile (e.g. "p3."), so each
// station profile keeps its own complete set of settings. Empty = unscoped
// (used briefly at startup before the active profile is known).
prefix string
// cache holds every setting's RAW (as-stored) value, loaded once. Reads are
// served from memory so the Preferences dialog (dozens of keys) doesn't pay
// a network round-trip per key against a remote MySQL. Decryption still
@@ -39,6 +45,50 @@ type Store struct {
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// CopyProfile duplicates every setting from one profile to another (used when
// a profile is duplicated), preserving raw/encrypted values verbatim.
func (s *Store) CopyProfile(ctx context.Context, srcID, dstID int64) error {
if err := s.ensureCache(ctx); err != nil {
return err
}
srcPrefix := fmt.Sprintf("p%d.", srcID)
dstPrefix := fmt.Sprintf("p%d.", dstID)
s.mu.RLock()
pairs := make(map[string]string)
for k, v := range s.cache {
if strings.HasPrefix(k, srcPrefix) {
pairs[dstPrefix+strings.TrimPrefix(k, srcPrefix)] = v
}
}
s.mu.RUnlock()
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
for fullKey, val := range pairs {
if _, err := s.db.ExecContext(ctx, q, fullKey, val, db.NowISO()); err != nil {
return err
}
s.cachePut(fullKey, val)
}
return nil
}
// SetProfile scopes all subsequent reads/writes to the given profile id, so
// each profile has its own settings. Called at startup and whenever the active
// profile changes.
func (s *Store) SetProfile(id int64) {
s.mu.Lock()
s.prefix = fmt.Sprintf("p%d.", id)
s.mu.Unlock()
}
// profileKey returns the storage key for the active profile.
func (s *Store) profileKey(key string) string {
s.mu.RLock()
p := s.prefix
s.mu.RUnlock()
return p + key
}
// ensureCache lazily loads all settings into memory on first read. A concurrent
// double-load is harmless (the result is identical), so it's done without a
// long-held lock.
@@ -167,8 +217,9 @@ func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
if err := s.ensureCache(ctx); err != nil {
return "", err
}
full := s.profileKey(key)
s.mu.RLock()
v := s.cache[key] // "" when absent
v := s.cache[full] // "" when absent
s.mu.RUnlock()
return v, nil
}
@@ -178,12 +229,13 @@ func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
// per-operator, never on the shared MySQL logbook), so SQLite syntax is used
// unconditionally. The backticks around `key` are accepted by SQLite too.
func (s *Store) SetRaw(ctx context.Context, key, value string) error {
full := s.profileKey(key)
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
if _, err := s.db.ExecContext(ctx, q, full, value, db.NowISO()); err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
s.cachePut(key, value)
s.cachePut(full, value)
return nil
}
@@ -192,15 +244,44 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
return s.SetRaw(ctx, key, s.encodeWrite(key, value))
}
// GetGlobal reads a value stored WITHOUT the profile prefix — for settings that
// are shared across every profile (e.g. award definitions, which are the
// operator's own work and shouldn't be re-created per profile). Sensitive
// decryption still applies.
func (s *Store) GetGlobal(ctx context.Context, key string) (string, error) {
if err := s.ensureCache(ctx); err != nil {
return "", err
}
s.mu.RLock()
v := s.cache[key]
s.mu.RUnlock()
return s.decodeRead(key, v), nil
}
// SetGlobal upserts a value WITHOUT the profile prefix (shared across profiles).
func (s *Store) SetGlobal(ctx context.Context, key, value string) error {
value = s.encodeWrite(key, value)
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil {
return fmt.Errorf("set global %s: %w", key, err)
}
s.cachePut(key, value)
return nil
}
// All returns every stored setting (sensitive values decrypted when unlocked).
func (s *Store) All(ctx context.Context) (map[string]string, error) {
if err := s.ensureCache(ctx); err != nil {
return nil, err
}
prefix := s.profileKey("")
s.mu.RLock()
raw := make(map[string]string, len(s.cache))
for k, v := range s.cache {
raw[k] = v
if strings.HasPrefix(k, prefix) {
raw[strings.TrimPrefix(k, prefix)] = v
}
}
s.mu.RUnlock()
out := make(map[string]string, len(raw))
@@ -221,10 +302,11 @@ func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string,
if err := s.ensureCache(ctx); err != nil {
return nil, err
}
prefix := s.profileKey("")
s.mu.RLock()
raw := make([]string, len(keys))
for i, k := range keys {
raw[i] = s.cache[k]
raw[i] = s.cache[prefix+k]
}
s.mu.RUnlock()
for i, k := range keys {