// 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 /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("opslog-%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 opslog-*.db / opslog-*.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, "opslog-") { 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, "opslog-"+stamp+ext)); err == nil { return true } } return false }