feat(backup): "keep every backup" option — timestamped files per exit

Sub-option of "back up on every exit": when on, backup.Run/RunADIF stamp the
filename with the time (opslog-YYYY-MM-DD-HHMMSS.db) so each run is a distinct
file instead of overwriting the day's snapshot. Rotation still trims to the
newest N (raise Rotation for a longer history). Threaded as a `unique` flag
through runConfiguredBackup/backupLogADIF from BackupSettings.KeepAll
(keyBackupKeepAll); UI sub-checkbox shown only when EveryExit is on.
This commit is contained in:
2026-08-07 12:28:52 +02:00
parent e9feeffdc3
commit 949cc17ed1
5 changed files with 50 additions and 22 deletions
+14 -10
View File
@@ -296,6 +296,7 @@ const (
keyBackupRotation = "backup.rotation"
keyBackupZip = "backup.zip"
keyBackupEveryExit = "backup.every_exit" // "1" → back up on every quit, not just once/day
keyBackupKeepAll = "backup.keep_all" // "1" → each backup is a distinct time-stamped file (no same-day overwrite)
keyBackupLast = "backup.last_at"
keyQSLDefaultQSLSent = "qsl.qsl_sent"
@@ -1535,7 +1536,7 @@ func (a *App) runBackupForShutdown() error {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return nil
}
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
return err
}
return a.settings.Set(a.ctx, keyBackupLast, time.Now().UTC().Format(time.RFC3339))
@@ -11784,6 +11785,7 @@ type BackupSettings struct {
Rotation int `json:"rotation"`
Zip bool `json:"zip"`
EveryExit bool `json:"every_exit"` // back up on every quit, not just the first of the day
KeepAll bool `json:"keep_all"` // time-stamped file per backup instead of one-per-day (kept up to Rotation)
LastBackupAt string `json:"last_backup_at"`
DefaultFolder string `json:"default_folder"` // computed, read-only — shown as a hint
}
@@ -11798,7 +11800,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
return out, nil
}
m, err := a.settings.GetMany(a.ctx,
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupLast)
keyBackupEnabled, keyBackupFolder, keyBackupRotation, keyBackupZip, keyBackupEveryExit, keyBackupKeepAll, keyBackupLast)
if err != nil {
return out, err
}
@@ -11809,6 +11811,7 @@ func (a *App) GetBackupSettings() (BackupSettings, error) {
}
out.Zip = m[keyBackupZip] == "1"
out.EveryExit = m[keyBackupEveryExit] == "1"
out.KeepAll = m[keyBackupKeepAll] == "1"
out.LastBackupAt = m[keyBackupLast]
return out, nil
}
@@ -11836,6 +11839,7 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
keyBackupRotation: strconv.Itoa(s.Rotation),
keyBackupZip: doZip,
keyBackupEveryExit: boolStr(s.EveryExit),
keyBackupKeepAll: boolStr(s.KeepAll),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -11852,15 +11856,15 @@ func (a *App) SaveBackupSettings(s BackupSettings) error {
// 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) {
func (a *App) runConfiguredBackup(folder string, rotation int, zip, unique 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 {
if _, err := backup.Run(a.ctx, a.db, a.dbPath, folder, rotation, zip, "opslogcfg", unique); 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)
return a.backupLogADIF(folder, rotation, zip, unique)
}
// 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).
@@ -11868,7 +11872,7 @@ func (a *App) runConfiguredBackup(folder string, rotation int, zip bool) (string
if conn == nil || path == "" {
conn, path = a.db, a.dbPath
}
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog")
return backup.Run(a.ctx, conn, path, folder, rotation, zip, "opslog", unique)
}
// RunBackupNow forces an immediate backup using the persisted settings.
@@ -11882,7 +11886,7 @@ func (a *App) RunBackupNow() (string, error) {
if folder == "" {
folder = s.DefaultFolder
}
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip)
path, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll)
if err != nil {
return path, err
}
@@ -11893,11 +11897,11 @@ func (a *App) RunBackupNow() (string, error) {
// backupLogADIF writes a rotating ADIF export of the (MySQL) logbook into the
// backup folder. The full set of ADIF + app fields is included so the backup is
// a complete, re-importable copy of the log.
func (a *App) backupLogADIF(folder string, rotation int, zip bool) (string, error) {
func (a *App) backupLogADIF(folder string, rotation int, zip, unique bool) (string, error) {
if a.qso == nil {
return "", fmt.Errorf("logbook not initialized")
}
return backup.RunADIF(folder, rotation, zip, func(p string) error {
return backup.RunADIF(folder, rotation, zip, unique, func(p string) error {
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: true}
_, e := ex.ExportFile(a.ctx, p)
return e
@@ -11931,7 +11935,7 @@ func (a *App) maybeShutdownBackup() {
if done && !s.EveryExit { // "every exit" backs up regardless of a prior backup today
return
}
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip); err != nil {
if _, err := a.runConfiguredBackup(folder, s.Rotation, s.Zip, s.KeepAll); err != nil {
fmt.Println("OpsLog: shutdown backup failed:", err)
return
}