up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬es,
|
||||
&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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user