201 lines
5.9 KiB
Go
201 lines
5.9 KiB
Go
// Package backup creates rotating copies of the SQLite logbook so a single
|
|
// disk failure or accidental delete doesn't wipe the user's QSO history.
|
|
// The strategy is intentionally simple: a daily snapshot of the .db file
|
|
// (optionally zipped), keeping the last N. SQLite's atomic-commit format
|
|
// lets us copy the raw file safely as long as we copy after a checkpoint.
|
|
package backup
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Settings holds the user-tweakable backup configuration. Stored as flat
|
|
// key/value pairs in the settings table (so we don't need a schema change
|
|
// every time we add a field).
|
|
type Settings struct {
|
|
Enabled bool `json:"enabled"`
|
|
Folder string `json:"folder"` // empty → DefaultFolder
|
|
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
|
|
Zip bool `json:"zip"` // compress with deflate
|
|
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never
|
|
}
|
|
|
|
// DefaultFolder returns the folder used when Settings.Folder is empty.
|
|
// It sits next to the database file (in <appdata>/hamlog/backups) which
|
|
// keeps it portable when the user moves the data directory.
|
|
func DefaultFolder(dataDir string) string {
|
|
return filepath.Join(dataDir, "backups")
|
|
}
|
|
|
|
// Run executes one backup pass: checkpoint WAL → copy the database file
|
|
// → optionally zip → rotate. Returns the path of the file that was
|
|
// written so the caller can surface it to the UI.
|
|
//
|
|
// dbConn is used to issue the WAL checkpoint so the on-disk file is
|
|
// self-consistent before we copy it. It's the same *sql.DB the app uses;
|
|
// SQLite tolerates concurrent reads during the copy.
|
|
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
|
|
if dbConn == nil {
|
|
return "", fmt.Errorf("nil db connection")
|
|
}
|
|
if rotation <= 0 {
|
|
rotation = 5
|
|
}
|
|
if folder == "" {
|
|
return "", fmt.Errorf("backup folder not set")
|
|
}
|
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
|
return "", fmt.Errorf("create backup folder: %w", err)
|
|
}
|
|
// Flush WAL into the main file so a raw copy is a complete database.
|
|
// TRUNCATE removes the -wal file's contents after checkpointing.
|
|
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
|
|
return "", fmt.Errorf("wal_checkpoint: %w", err)
|
|
}
|
|
|
|
stamp := time.Now().Format("2006-01-02")
|
|
base := fmt.Sprintf("hamlog-%s", stamp)
|
|
var dstPath string
|
|
if doZip {
|
|
dstPath = filepath.Join(folder, base+".db.zip")
|
|
if err := copyZipped(dbPath, dstPath); err != nil {
|
|
return "", err
|
|
}
|
|
} else {
|
|
dstPath = filepath.Join(folder, base+".db")
|
|
if err := copyFile(dbPath, dstPath); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
if err := rotate(folder, rotation); err != nil {
|
|
// Rotation errors are non-fatal — the backup itself succeeded.
|
|
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
|
|
}
|
|
return dstPath, nil
|
|
}
|
|
|
|
// copyFile performs a plain file copy. We don't use os.Rename because
|
|
// the source is the live database; we want a fresh standalone file.
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return fmt.Errorf("open source: %w", err)
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return fmt.Errorf("create dest: %w", err)
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return fmt.Errorf("copy: %w", err)
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
// copyZipped writes a single-entry deflate zip containing the database.
|
|
// The inner filename is just the base of the source so unzip restores
|
|
// "hamlog.db" wherever the user extracts.
|
|
func copyZipped(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return fmt.Errorf("open source: %w", err)
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return fmt.Errorf("create dest: %w", err)
|
|
}
|
|
zw := zip.NewWriter(out)
|
|
w, err := zw.Create(filepath.Base(src))
|
|
if err != nil {
|
|
zw.Close()
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return fmt.Errorf("zip entry: %w", err)
|
|
}
|
|
if _, err := io.Copy(w, in); err != nil {
|
|
zw.Close()
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return fmt.Errorf("zip write: %w", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
out.Close()
|
|
os.Remove(dst)
|
|
return fmt.Errorf("zip close: %w", err)
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
// rotate keeps the most recent `keep` backups in folder and deletes the
|
|
// rest. Only files matching the hamlog-*.db / hamlog-*.db.zip pattern
|
|
// are touched — never user files that happen to live in the same folder.
|
|
func rotate(folder string, keep int) error {
|
|
entries, err := os.ReadDir(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type item struct {
|
|
path string
|
|
mod time.Time
|
|
}
|
|
matches := make([]item, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, "hamlog-") {
|
|
continue
|
|
}
|
|
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()})
|
|
}
|
|
if len(matches) <= keep {
|
|
return nil
|
|
}
|
|
sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) })
|
|
var firstErr error
|
|
for _, m := range matches[keep:] {
|
|
if err := os.Remove(m.path); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// HasBackupToday returns true if a backup for today's date already exists
|
|
// in folder. Used by the startup auto-backup to skip when the user has
|
|
// already restarted the app once today.
|
|
func HasBackupToday(folder string) bool {
|
|
if folder == "" {
|
|
return false
|
|
}
|
|
stamp := time.Now().Format("2006-01-02")
|
|
for _, ext := range []string{".db", ".db.zip"} {
|
|
if _, err := os.Stat(filepath.Join(folder, "hamlog-"+stamp+ext)); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|