fix(backup): back up the contacts (logbook), not just the settings db

Since the logbook was split into its own SQLite file, backup.Run was still
snapshotting a.db (the settings/config database) — so the scheduled and manual
backups silently stopped including the QSOs. The operator was backing up config
and thinking it was their log.

Track the resolved logbook file path (a.logDbPath, set in connectLogbook) and
route the backup through a new runConfiguredBackup: the CONTACTS become the
primary "opslog-*" backup (the logbook file on SQLite, an ADIF export on MySQL),
and the settings/config db is snapshotted separately as "opslogcfg-*" so nothing
is lost. backup.Run takes a name prefix; the two sets rotate independently.
This commit is contained in:
2026-08-06 23:22:04 +02:00
parent 6e2e2cc3aa
commit e2bfe73bdb
3 changed files with 45 additions and 35 deletions
+35 -24
View File
@@ -644,6 +644,7 @@ type App struct {
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
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
logDbPath string // resolved SQLite file actually backing logDb ("" on MySQL, or when the settings db serves as the logbook) — the file the backup snapshots
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
@@ -1066,6 +1067,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook open failed (%v) — falling back to SQLite logbook", lerr)
a.dbBackendErr = strings.TrimPrefix(lerr.Error(), "")
logbookConn, backend = conn, "sqlite"
a.logDbPath = "" // fell back to the settings db as the logbook — backup snapshots a.db
}
a.dbBackend = backend
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
@@ -1523,14 +1525,9 @@ func (a *App) runBackupForShutdown() error {
if done {
return nil
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
return err
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
return err
}
}
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
@@ -2053,6 +2050,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if err != nil {
return nil, "", err
}
a.logDbPath = "" // MySQL: no local file to snapshot (the log is exported to ADIF instead)
return c, "mysql", nil
}
// SQLite logbook FILE, separate from the settings/config database. A profile
@@ -2066,6 +2064,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
lp = a.logbookPath
}
if lp == "" {
a.logDbPath = "" // settings db serves as the logbook (split failed) — backup snapshots a.db
return a.db, "sqlite", nil
}
// Resolve against THIS install before opening. Without it, a profile carried
@@ -2081,6 +2080,7 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
if err != nil {
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
}
a.logDbPath = lp // the SQLite file the backup snapshots for the contacts
return c, "sqlite", nil
}
@@ -11815,6 +11815,33 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
return nil
}
// runConfiguredBackup writes the backup set to folder: the CONTACTS as the
// primary "opslog-*" backup, plus a separate "opslogcfg-*" snapshot of the
// settings/config db, so neither is lost. The contacts are what the user means
// by "the log": on SQLite they live in the split-out logbook file (a.logDbPath),
// NOT the settings db — the old code snapshotted a.db and so silently stopped
// backing up the QSOs once the logbook was split out. On MySQL the contacts
// aren't in a local file, so they're exported to ADIF instead. Returns the path
// of the contacts backup.
func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string, error) {
// Config snapshot (profiles, hardware, awards). Best-effort — a config-backup
// failure must never stop the contacts from being protected.
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg"); err != nil {
applog.Printf("backup: config snapshot failed: %v", err)
}
if a.dbBackend == "mysql" {
// The live log is on MySQL; VACUUM INTO can't reach it — export to ADIF.
return a.backupLogADIF(folder, rotation, zip)
}
// SQLite: snapshot the logbook file that holds the contacts. Fall back to the
// settings db only when it doubles as the logbook (rare split-failure case).
conn, path := a.logDb, a.logDbPath
if conn == nil || path == "" {
conn, path = a.db, a.dbPath
}
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog")
}
// RunBackupNow forces an immediate backup using the persisted settings.
// Returns the destination path of the file that was written.
func (a *App) RunBackupNow() (string, error) {
@@ -11826,20 +11853,10 @@ func (a *App) RunBackupNow() (string, error) {
if folder == "" {
folder = s.DefaultFolder
}
// Always snapshot the local SQLite (config + any pre-MySQL local QSOs).
path, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip)
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip)
if err != nil {
return path, err
}
// On MySQL the live QSO log isn't in the local DB — export it to ADIF so the
// contacts are actually protected. The ADIF path is the one we surface.
if a.dbBackend == "mysql" {
adiPath, aerr := a.backupLogADIF(folder, s.Rotation, s.Zip)
if aerr != nil {
return adiPath, aerr
}
path = adiPath
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
return path, nil
}
@@ -11885,16 +11902,10 @@ func (a *App) maybeShutdownBackup() {
if done {
return
}
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err)
return
}
if mysql {
if _, err := a.backupLogADIF(folder, s.Rotation, s.Zip); err != nil {
fmt.Println("OpsLog: shutdown ADIF log backup failed:", err)
return
}
}
a.setSetting(keyBackupLast, time.Now().UTC().Format(time.RFC3339))
}
+4 -2
View File
@@ -3,10 +3,12 @@
"version": "0.23.8",
"date": "",
"en": [
"E-mail: the QSO recording e-mail (subject and body) is now editable in Settings → E-mail, like the QSL card e-mail — with the {CALL} {DATE} {BAND} {MODE} {MYCALL} variables."
"E-mail: the QSO recording e-mail (subject and body) is now editable in Settings → E-mail, like the QSL card e-mail — with the {CALL} {DATE} {BAND} {MODE} {MYCALL} variables.",
"Backup fix: the backup now saves your CONTACTS (the logbook), not just the settings. Once the logbook was split into its own file, the backup kept snapshotting the settings database and silently missed the QSOs. It now writes the log to opslog-*.db and the configuration separately to opslogcfg-*.db (MySQL logs still export to ADIF)."
],
"fr": [
"E-mail : le texte de l'e-mail d'enregistrement QSO (objet et corps) est désormais modifiable dans Réglages → E-mail, comme l'e-mail de carte QSL — avec les variables {CALL} {DATE} {BAND} {MODE} {MYCALL}."
"E-mail : le texte de l'e-mail d'enregistrement QSO (objet et corps) est désormais modifiable dans Réglages → E-mail, comme l'e-mail de carte QSL — avec les variables {CALL} {DATE} {BAND} {MODE} {MYCALL}.",
"Correction sauvegarde : la sauvegarde enregistre désormais tes CONTACTS (le journal), et plus seulement les réglages. Depuis que le journal a été séparé dans son propre fichier, la sauvegarde continuait à copier la base des réglages et oubliait les QSO. Elle écrit maintenant le log dans opslog-*.db et la configuration à part dans opslogcfg-*.db (les logs MySQL restent exportés en ADIF)."
]
},
{
+6 -9
View File
@@ -44,7 +44,7 @@ func DefaultFolder(dataDir string) string {
// statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
}
@@ -54,12 +54,15 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if prefix == "" {
prefix = "opslog"
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp)
base := fmt.Sprintf("%s-%s", prefix, stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place.
@@ -92,7 +95,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
}
}
if err := rotate(folder, rotation); err != nil {
if err := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); err != nil {
// Rotation errors are non-fatal — the backup itself succeeded.
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
@@ -203,12 +206,6 @@ func copyZipped(src, dst, innerName string) error {
return out.Close()
}
// rotate keeps the most recent `keep` SQLite backups (opslog-*.db /
// opslog-*.db.zip) and deletes the rest.
func rotate(folder string, keep int) error {
return rotateMatch(folder, keep, "opslog-", ".db", ".db.zip")
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix