feat: split settings DB from the QSO logbook (own file per profile)

Design flaw: opslog.db held BOTH the config (settings + profiles) AND the SQLite
logbook, so the only way to a separate SQLite logbook — the whole-app "change
database location" pointer — swapped the config too, wiping the operator's setup
(reported: creating a new SQLite for a visiting op lost all profiles/settings).

Now the two are separate files:
- Settings database: settings + profiles. Fresh installs name it settings.db;
  existing installs keep opslog.db.
- Logbook (QSOs): a dedicated logbook.db next to the settings db, or a
  per-profile file (profile.ProfileDB.Path), or MySQL. connectLogbook opens the
  logbook file (db.Open creates+migrates on first use); the settings db is used
  as the logbook only if the split ever fails.

One-time, non-destructive migration at startup: if there's no logbook file yet
but the settings db already holds QSOs (legacy combined opslog.db), seed
logbook.db with a clean copy via VACUUM INTO — contacts move to the logbook, the
originals stay in the settings db as an untouched backup.

UI: the Database panel now shows the settings database and this profile's logbook
as two clearly labelled sections (+ "Open folder"), and the logbook selector is
SQLite (default logbook.db, or a dedicated file via "Choose a dedicated file…")
vs MySQL — no more confusing shared/separate choice. New binding RevealDataFolder.
This commit is contained in:
2026-07-24 18:13:08 +02:00
parent 638ffcb326
commit 2b3d118d84
8 changed files with 205 additions and 131 deletions
+90 -19
View File
@@ -515,8 +515,9 @@ type App struct {
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
@@ -711,7 +712,16 @@ func (a *App) startup(ctx context.Context) {
return
}
a.dataDir = dataDir
a.dbPath = filepath.Join(dataDir, "opslog.db")
// Settings/config database (settings + profiles). Fresh installs use
// settings.db; existing installs keep their opslog.db (which also held the
// QSOs before they were split into a dedicated logbook file — see below).
settingsDefault := filepath.Join(dataDir, "settings.db")
legacyOpslog := filepath.Join(dataDir, "opslog.db")
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
a.dbPath = legacyOpslog
} else {
a.dbPath = settingsDefault
}
usingDefault := true
// config.json (in the data dir) may point the database to a user-chosen
// location — e.g. another drive or a synced folder, so it survives a
@@ -783,6 +793,23 @@ func (a *App) startup(ctx context.Context) {
}
a.db = conn
// The QSO logbook lives in its OWN file (logbook.db) next to the settings db,
// so QSOs never share the settings/profiles database. One-time split for
// existing installs: if there's no logbook file yet but the settings db
// already holds QSOs (the legacy combined opslog.db), seed logbook.db with a
// clean copy (VACUUM INTO) — the contacts move to the logbook while the
// originals stay in the settings db as an untouched backup. Non-destructive.
a.logbookPath = filepath.Join(filepath.Dir(a.dbPath), "logbook.db")
if !fileExists(a.logbookPath) && sqliteHasQSOs(a.db) {
esc := strings.ReplaceAll(a.logbookPath, "'", "''")
if _, verr := a.db.Exec("VACUUM INTO '" + esc + "'"); verr != nil {
applog.Printf("logbook split: VACUUM INTO %s failed (%v) — the settings db will serve as the logbook", a.logbookPath, verr)
a.logbookPath = "" // fall back to using the settings db as the logbook
} else {
fmt.Printf("OpsLog: split logbook — seeded %s from the existing database (originals kept as backup)\n", a.logbookPath)
}
}
// Wire the LOCAL config repos first — they're backed by the already-open
// SQLite file, so the station/profiles/settings are ready instantly. Doing
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
@@ -1542,15 +1569,39 @@ func writeDBPointer(dataDir, path string) error {
// DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct {
Path string `json:"path"`
DefaultPath string `json:"default_path"`
IsCustom bool `json:"is_custom"`
Path string `json:"path"` // settings/config database (settings + profiles)
DefaultPath string `json:"default_path"` // where the settings db lives by default
IsCustom bool `json:"is_custom"` // config.json points it elsewhere
LogbookDefaultPath string `json:"logbook_default_path"` // default SQLite logbook file (QSOs), when a profile doesn't point elsewhere
}
// GetDatabaseSettings returns where the active database lives.
func (a *App) GetDatabaseSettings() DatabaseSettings {
def := filepath.Join(a.dataDir, "opslog.db")
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
// Default settings-db location: settings.db on fresh installs, opslog.db when
// an existing one is present (mirrors the startup resolution).
settingsDefault := filepath.Join(a.dataDir, "settings.db")
legacyOpslog := filepath.Join(a.dataDir, "opslog.db")
def := settingsDefault
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
def = legacyOpslog
}
lp := a.logbookPath
if lp == "" {
lp = a.dbPath // split disabled → the settings db doubles as the logbook
}
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def, LogbookDefaultPath: lp}
}
// RevealDataFolder opens the folder that holds the settings database (and the
// default logbook) in the OS file manager — the "where is my data" shortcut.
func (a *App) RevealDataFolder() error {
dir := filepath.Dir(a.dbPath)
return openInFileManager(dir)
}
// openInFileManager opens a folder in Windows Explorer (matches OpenAwardsFolder).
func openInFileManager(dir string) error {
return exec.Command("explorer", dir).Start()
}
// MySQLSettings is the shared-database (multi-operator) connection config. When
@@ -1623,18 +1674,38 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
}
return c, "mysql", nil
}
// A per-profile SQLite logbook FILE (separate from the app/config database).
// db.Open creates it and runs the schema migrations if it doesn't exist yet,
// so pointing a profile at a fresh name just works. Empty path = the shared
// app database (a.db) is the logbook, as before.
if p := strings.TrimSpace(cfg.Path); p != "" {
c, err := db.Open(p)
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", p, err)
}
return c, "sqlite", nil
// SQLite logbook FILE, separate from the settings/config database. A profile
// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
// no path it uses the default logbook.db beside the settings db. db.Open
// creates + migrates the file if it doesn't exist yet. Only when there is no
// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
// settings db itself as the logbook.
lp := strings.TrimSpace(cfg.Path)
if lp == "" {
lp = a.logbookPath
}
return a.db, "sqlite", nil
if lp == "" {
return a.db, "sqlite", nil
}
c, err := db.Open(lp)
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
}
return c, "sqlite", nil
}
// sqliteHasQSOs reports whether the given (SQLite) database has at least one QSO
// row — used once at startup to decide whether to seed the split-out logbook
// file from a legacy combined database. Missing table / any error → false.
func sqliteHasQSOs(conn *sql.DB) bool {
if conn == nil {
return false
}
var n int
if err := conn.QueryRow("SELECT EXISTS(SELECT 1 FROM qso)").Scan(&n); err != nil {
return false
}
return n > 0
}
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active