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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user