diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 72f3d41..de44540 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -4188,6 +4188,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typed-path"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -4307,6 +4313,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"uuid",
+ "zip",
]
[[package]]
@@ -5182,6 +5189,19 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "zip"
+version = "8.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "indexmap 2.14.2",
+ "memchr",
+ "typed-path",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 0bcf23b..ed20944 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -28,6 +28,7 @@ thiserror = "2"
base64 = "0.22"
sha2 = "0.10"
flate2 = "1"
+zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
percent-encoding = "2"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs
new file mode 100644
index 0000000..f2d7549
--- /dev/null
+++ b/src-tauri/src/commands/backup.rs
@@ -0,0 +1,1502 @@
+//! Backup, restore and the daily automatic backup.
+//!
+//! # Archive format
+//! A plain zip (the pure-Rust `zip` crate, deflate only) with these entries, all regular files:
+//! `voiced.db` (a `VACUUM INTO` snapshot, consistent while the app runs), `assets/**` (data dir),
+//! `archive/**` and `fonts/**` (local data dir), and `manifest.json` (written last, so it can describe
+//! exactly what was streamed): format, app version, schema `user_version`, creation time and every file
+//! with its sha256 and size. Files are hashed while they are copied into the zip, one at a time, so no
+//! archive set is ever held in memory.
+//!
+//! # Restore design (stage now, swap on the next start)
+//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
+//! so swapping them live is unsafe. Restore therefore has two phases:
+//!
+//! 1. `restore_backup` (`stage_restore`): validate the manifest (format, `schema_version <= LATEST_VERSION`,
+//! entry names, size caps), check the zip holds exactly the manifest's files and nothing else (no symlinks,
+//! no traversal, allow-listed top-level names), extract into `
/pending-restore/` while hashing against
+//! the manifest, then open the staged DB read-only and require `integrity_check = ok` and a matching
+//! `user_version`. Only then is `/pending_restore.json` written. The live data is not touched.
+//! Staging lives in the same directory as its destination (`` for the DB and assets, `` for the
+//! archive and fonts) so the final move is a rename.
+//! 2. `apply_pending_restore` runs in `init_state` after the directories exist and BEFORE the database is
+//! opened. It moves the current `voiced.db` (with its `-wal`/`-shm` sidecars, so an orphan WAL can never be
+//! replayed into the restored file), `assets/`, `archive/` and `fonts/` into
+//! `/backups/pre-restore-/` (never pruned, never deleted), then moves the staged files into
+//! place. Any failure rolls everything back and the app starts on the old data. The outcome is recorded in
+//! `/last_restore.json` for the Data tab. A restored older-schema DB is then migrated by `db::open`
+//! like any other (with its own pre-migration backup).
+//!
+//! `restart_app` relaunches the binary after a short delay (see `restart_delay_from_env`) so the
+//! single-instance lock of the exiting process is released before the new one starts.
+//!
+//! # Automatic backup
+//! A background thread writes `/backups/auto/voiced-auto-YYYYMMDD.zip` at most once per calendar day
+//! (checked now and then hourly while the app stays open), keeps the newest 14 and logs failures. It opens its
+//! own SQLite connection, so it never contends for the app's database mutex.
+use super::raw::write_atomic;
+use crate::db::{has_user_tables, vacuum_into, LATEST_VERSION};
+use crate::AppState;
+use chrono::{DateTime, Local, NaiveDate};
+use rusqlite::{Connection, OpenFlags};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::collections::{BTreeMap, BTreeSet};
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::path::{Path, PathBuf};
+use tauri::State;
+
+pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
+/// Automatic backups kept in `backups/auto`.
+pub const AUTO_KEEP: usize = 14;
+
+const MANIFEST_NAME: &str = "manifest.json";
+const DB_ENTRY: &str = "voiced.db";
+const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
+const MARKER_NAME: &str = "pending_restore.json";
+const LAST_RESTORE_NAME: &str = "last_restore.json";
+const STAGING_NAME: &str = "pending-restore";
+const AUTO_PREFIX: &str = "voiced-auto-";
+const AUTO_SUFFIX: &str = ".zip";
+
+const MAX_ENTRIES: usize = 200_000;
+const MAX_MANIFEST_BYTES: u64 = 32 * 1024 * 1024;
+const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024 * 1024;
+const MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024 * 1024;
+const MAX_NAME_LEN: usize = 512;
+
+/// The two data roots. On Linux they are the same directory; on Windows `data` is %APPDATA% and `local`
+/// is %LOCALAPPDATA%.
+#[derive(Debug, Clone)]
+pub struct DataDirs {
+ pub data: PathBuf,
+ pub local: PathBuf,
+}
+
+impl DataDirs {
+ pub fn new(data: &Path, local: &Path) -> Self {
+ Self { data: data.to_path_buf(), local: local.to_path_buf() }
+ }
+ fn from_state(state: &AppState) -> Self {
+ Self::new(&state.data_dir, &state.local_data_dir)
+ }
+ pub fn db(&self) -> PathBuf {
+ self.data.join(DB_ENTRY)
+ }
+ pub fn backups(&self) -> PathBuf {
+ self.data.join("backups")
+ }
+ pub fn auto_dir(&self) -> PathBuf {
+ self.backups().join("auto")
+ }
+ fn marker(&self) -> PathBuf {
+ self.data.join(MARKER_NAME)
+ }
+ fn last_restore(&self) -> PathBuf {
+ self.data.join(LAST_RESTORE_NAME)
+ }
+ /// Where a root (`assets`, `archive`, `fonts`) lives.
+ fn root_dir(&self, root: &str) -> PathBuf {
+ match root {
+ "assets" => self.data.join("assets"),
+ _ => self.local.join(root),
+ }
+ }
+ /// Staging directory on the same volume as the destination of an entry.
+ fn staging_for(&self, first_component: &str) -> PathBuf {
+ match first_component {
+ "archive" | "fonts" => self.local.join(STAGING_NAME),
+ _ => self.data.join(STAGING_NAME),
+ }
+ }
+ fn staging_dirs(&self) -> [PathBuf; 2] {
+ [self.data.join(STAGING_NAME), self.local.join(STAGING_NAME)]
+ }
+ fn clear_staging(&self) {
+ for dir in self.staging_dirs() {
+ let _ = fs::remove_dir_all(dir);
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ManifestFile {
+ pub path: String,
+ pub sha256: String,
+ pub size: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Manifest {
+ pub format: String,
+ pub app_version: String,
+ /// `PRAGMA user_version` of the snapshot.
+ pub schema_version: i64,
+ pub created_at: String,
+ pub files: Vec,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupSummary {
+ pub path: String,
+ pub created_at: String,
+ pub schema_version: i64,
+ pub file_count: usize,
+ pub total_bytes: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PendingRestore {
+ pub backup_name: String,
+ pub backup_created_at: String,
+ pub app_version: String,
+ pub schema_version: i64,
+ pub file_count: usize,
+ pub total_bytes: u64,
+ pub staged_at: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LastRestore {
+ pub ok: bool,
+ pub at: String,
+ pub backup_name: String,
+ pub message: String,
+ pub safety_dir: Option,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupStatus {
+ pub auto_backup: bool,
+ pub auto_dir: String,
+ pub last_auto_backup: Option,
+ pub auto_backup_count: usize,
+ pub pending_restore: Option,
+ pub last_restore: Option,
+}
+
+// ---------------------------------------------------------------- helpers
+
+/// Removes a temp file when dropped, unless `keep` was called.
+struct TempFile(PathBuf);
+impl Drop for TempFile {
+ fn drop(&mut self) {
+ let _ = fs::remove_file(&self.0);
+ }
+}
+
+fn sibling_with_suffix(path: &Path, suffix: &str) -> Result {
+ let mut name = path
+ .file_name()
+ .ok_or_else(|| "The path has no file name".to_string())?
+ .to_os_string();
+ name.push(suffix);
+ Ok(path.with_file_name(name))
+}
+
+fn hex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn io_err(what: &str, path: &Path, e: std::io::Error) -> String {
+ format!("{what} {}: {e}", path.display())
+}
+
+fn user_version(conn: &Connection) -> rusqlite::Result {
+ conn.pragma_query_value(None, "user_version", |r| r.get(0))
+}
+
+/// Entry names: forward-slash relative paths under an allow-listed top level. Rejects absolute paths, drive
+/// letters, backslashes, `.`/`..`/empty components, control characters and Windows-hostile names.
+fn validate_entry_path(name: &str) -> Result<(), String> {
+ let bad = |why: &str| Err(format!("Unsafe file name in the backup ({why}): {name:?}"));
+ if name.is_empty() || name.len() > MAX_NAME_LEN {
+ return bad("empty or too long");
+ }
+ if name.starts_with('/') || name.contains('\\') || name.contains(':') || name.chars().any(|c| c.is_control()) {
+ return bad("absolute, backslash, colon or control character");
+ }
+ let parts: Vec<&str> = name.split('/').collect();
+ for p in &parts {
+ if p.is_empty() || *p == "." || *p == ".." || p.ends_with('.') || p.ends_with(' ') {
+ return bad("path traversal or invalid component");
+ }
+ }
+ let allowed = name == DB_ENTRY || (parts.len() >= 2 && ROOTS.contains(&parts[0]));
+ if !allowed {
+ return bad("not a Voiced data file");
+ }
+ Ok(())
+}
+
+fn is_symlink_mode(mode: u32) -> bool {
+ mode & 0o170000 == 0o120000
+}
+
+/// Every regular file under `root`, as (zip entry name, path). Symlinks and other special files are skipped.
+fn collect_files(root_name: &str, root: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
+ fn walk(prefix: &str, dir: &Path, out: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
+ let entries = match fs::read_dir(dir) {
+ Ok(e) => e,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
+ Err(e) => return Err(io_err("Could not read", dir, e)),
+ };
+ for entry in entries {
+ let entry = entry.map_err(|e| io_err("Could not read", dir, e))?;
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let meta = match fs::symlink_metadata(entry.path()) {
+ Ok(m) => m,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ Err(e) => return Err(io_err("Could not read", &entry.path(), e)),
+ };
+ let entry_name = format!("{prefix}/{name}");
+ if meta.is_dir() {
+ walk(&entry_name, &entry.path(), out)?;
+ } else if meta.is_file() {
+ out.push((entry_name, entry.path()));
+ }
+ }
+ Ok(())
+ }
+ walk(root_name, root, out)
+}
+
+fn zip_options(entry: &str) -> zip::write::SimpleFileOptions {
+ // Archived PDFs and images are already compressed; the DB and fonts are not.
+ let method = if entry == DB_ENTRY || entry.starts_with("fonts/") {
+ zip::CompressionMethod::Deflated
+ } else {
+ zip::CompressionMethod::Stored
+ };
+ zip::write::SimpleFileOptions::default().compression_method(method).large_file(true)
+}
+
+/// Stream `src` into the zip as `entry`, hashing as it goes. Returns None if the file vanished.
+fn add_file(
+ zip: &mut zip::ZipWriter,
+ entry: &str,
+ src: &Path,
+) -> Result, String> {
+ let mut file = match File::open(src) {
+ Ok(f) => f,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(e) => return Err(io_err("Could not read", src, e)),
+ };
+ zip.start_file(entry, zip_options(entry)).map_err(|e| e.to_string())?;
+ let mut hasher = Sha256::new();
+ let mut size = 0u64;
+ let mut buf = vec![0u8; 64 * 1024];
+ loop {
+ let n = file.read(&mut buf).map_err(|e| io_err("Could not read", src, e))?;
+ if n == 0 {
+ break;
+ }
+ hasher.update(&buf[..n]);
+ zip.write_all(&buf[..n]).map_err(|e| format!("Could not write the backup: {e}"))?;
+ size += n as u64;
+ }
+ Ok(Some(ManifestFile { path: entry.to_string(), sha256: hex(&hasher.finalize()), size }))
+}
+
+// ---------------------------------------------------------------- create
+
+fn is_inside(path: &Path, dir: &Path) -> bool {
+ // Compare canonical parents when possible so a relative or symlinked spelling cannot slip through.
+ let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
+ let parent = path.parent().map(canon).unwrap_or_else(|| path.to_path_buf());
+ parent.starts_with(canon(dir))
+}
+
+pub fn create_backup_impl(dirs: &DataDirs, dest: &Path, now: DateTime) -> Result {
+ if !dest.is_absolute() {
+ return Err("The backup path must be absolute".to_string());
+ }
+ for root in ROOTS {
+ if is_inside(dest, &dirs.root_dir(root)) {
+ return Err(format!("Choose a folder outside the {root} folder for the backup"));
+ }
+ }
+ let parent = dest.parent().ok_or_else(|| "The backup path has no folder".to_string())?;
+ fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?;
+
+ // 1. Consistent DB snapshot through a second connection (WAL lets it read while the app writes).
+ let snapshot = TempFile(sibling_with_suffix(dest, &format!(".{}.db.tmp", uuid::Uuid::new_v4().simple()))?);
+ let schema_version = {
+ let conn = Connection::open(dirs.db()).map_err(|e| format!("Could not open the database: {e}"))?;
+ conn.busy_timeout(std::time::Duration::from_secs(10)).map_err(|e| e.to_string())?;
+ vacuum_into(&conn, &snapshot.0).map_err(|e| format!("Could not snapshot the database: {e}"))?;
+ user_version(&conn).map_err(|e| e.to_string())?
+ };
+
+ // 2. Stream everything into `.part`, then rename.
+ let part = sibling_with_suffix(dest, ".part")?;
+ let part_guard = TempFile(part.clone());
+ let mut files: Vec = Vec::new();
+ {
+ let out = File::create(&part).map_err(|e| io_err("Could not create", &part, e))?;
+ let mut zip = zip::ZipWriter::new(out);
+ match add_file(&mut zip, DB_ENTRY, &snapshot.0)? {
+ Some(f) => files.push(f),
+ None => return Err("The database snapshot disappeared".to_string()),
+ }
+ for root in ROOTS {
+ let mut found = Vec::new();
+ collect_files(root, &dirs.root_dir(root), &mut found)?;
+ found.sort();
+ for (entry, path) in found {
+ if let Some(f) = add_file(&mut zip, &entry, &path)? {
+ files.push(f);
+ }
+ }
+ }
+ let manifest = Manifest {
+ format: BACKUP_FORMAT.to_string(),
+ app_version: env!("CARGO_PKG_VERSION").to_string(),
+ schema_version,
+ created_at: now.to_rfc3339(),
+ files: files.clone(),
+ };
+ let text = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
+ zip.start_file(MANIFEST_NAME, zip_options(DB_ENTRY)).map_err(|e| e.to_string())?;
+ zip.write_all(&text).map_err(|e| format!("Could not write the backup: {e}"))?;
+ let out = zip.finish().map_err(|e| format!("Could not finish the backup: {e}"))?;
+ out.sync_all().map_err(|e| io_err("Could not flush", &part, e))?;
+ }
+ fs::rename(&part, dest).map_err(|e| io_err("Could not write", dest, e))?;
+ drop(part_guard); // the part file was renamed away, so this is a no-op
+ Ok(BackupSummary {
+ path: dest.to_string_lossy().into_owned(),
+ created_at: now.to_rfc3339(),
+ schema_version,
+ file_count: files.len(),
+ total_bytes: files.iter().map(|f| f.size).sum(),
+ })
+}
+
+// ---------------------------------------------------------------- stage (validate + extract)
+
+fn read_manifest(archive: &mut zip::ZipArchive) -> Result {
+ let entry = archive
+ .by_name(MANIFEST_NAME)
+ .map_err(|_| "This file is not a Voiced backup (no manifest.json)".to_string())?;
+ if entry.size() > MAX_MANIFEST_BYTES {
+ return Err("The backup manifest is too large".to_string());
+ }
+ let mut text = Vec::new();
+ entry
+ .take(MAX_MANIFEST_BYTES + 1)
+ .read_to_end(&mut text)
+ .map_err(|e| format!("Could not read the backup manifest: {e}"))?;
+ if text.len() as u64 > MAX_MANIFEST_BYTES {
+ return Err("The backup manifest is too large".to_string());
+ }
+ serde_json::from_slice(&text).map_err(|e| format!("The backup manifest is damaged: {e}"))
+}
+
+fn validate_manifest(m: &Manifest) -> Result<(), String> {
+ if m.format != BACKUP_FORMAT {
+ return Err(format!("Unsupported backup format {:?}", m.format));
+ }
+ if m.schema_version > LATEST_VERSION {
+ return Err(format!(
+ "This backup was made by a newer version of Voiced (database version {}, this app supports up to {}). Update Voiced first.",
+ m.schema_version, LATEST_VERSION
+ ));
+ }
+ if m.schema_version < 1 {
+ return Err("The backup has no valid database version".to_string());
+ }
+ if m.files.len() > MAX_ENTRIES {
+ return Err("The backup lists too many files".to_string());
+ }
+ let mut seen = BTreeSet::new();
+ let mut total = 0u64;
+ for f in &m.files {
+ validate_entry_path(&f.path)?;
+ if !seen.insert(f.path.as_str()) {
+ return Err(format!("The backup lists {:?} twice", f.path));
+ }
+ if f.size > MAX_FILE_BYTES {
+ return Err(format!("{:?} is larger than the allowed size", f.path));
+ }
+ if f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return Err(format!("{:?} has an invalid checksum in the manifest", f.path));
+ }
+ total = total.saturating_add(f.size);
+ }
+ if total > MAX_TOTAL_BYTES {
+ return Err("The backup is larger than the allowed size".to_string());
+ }
+ if !seen.contains(DB_ENTRY) {
+ return Err("The backup contains no database".to_string());
+ }
+ Ok(())
+}
+
+fn verify_staged_db(path: &Path, expected_version: i64) -> Result<(), String> {
+ let result = (|| -> Result<(), String> {
+ let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
+ .map_err(|e| format!("The backed-up database cannot be opened: {e}"))?;
+ let integrity: String = conn
+ .query_row("PRAGMA integrity_check", [], |r| r.get(0))
+ .map_err(|e| format!("The backed-up database failed its check: {e}"))?;
+ if integrity != "ok" {
+ return Err(format!("The backed-up database is damaged: {integrity}"));
+ }
+ let version = user_version(&conn).map_err(|e| e.to_string())?;
+ if version > LATEST_VERSION {
+ return Err(format!(
+ "The backed-up database is from a newer version of Voiced (version {version}, supported up to {LATEST_VERSION})"
+ ));
+ }
+ if version != expected_version || version < 1 {
+ return Err("The backed-up database version does not match its manifest".to_string());
+ }
+ if !has_user_tables(&conn).map_err(|e| e.to_string())? {
+ return Err("The backed-up database is empty".to_string());
+ }
+ Ok(())
+ })();
+ // A read-only open of a WAL database can leave sidecars; none may travel with the staged file.
+ for suffix in ["-wal", "-shm"] {
+ if let Ok(side) = sibling_with_suffix(path, suffix) {
+ let _ = fs::remove_file(side);
+ }
+ }
+ result
+}
+
+fn extract_entry(
+ archive: &mut zip::ZipArchive,
+ index: usize,
+ expected: &ManifestFile,
+ target: &Path,
+) -> Result<(), String> {
+ let entry = archive.by_index(index).map_err(|e| e.to_string())?;
+ if entry.size() != expected.size {
+ return Err(format!("{:?} does not match the size in the manifest", expected.path));
+ }
+ if let Some(parent) = target.parent() {
+ fs::create_dir_all(parent).map_err(|e| io_err("Could not create", parent, e))?;
+ }
+ // create_new: never write through something that already exists (a symlink, say).
+ let mut out = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(target)
+ .map_err(|e| io_err("Could not create", target, e))?;
+ let mut reader = entry.take(expected.size + 1);
+ let mut hasher = Sha256::new();
+ let mut written = 0u64;
+ let mut buf = vec![0u8; 64 * 1024];
+ loop {
+ let n = reader.read(&mut buf).map_err(|e| format!("Could not read {:?} from the backup: {e}", expected.path))?;
+ if n == 0 {
+ break;
+ }
+ written += n as u64;
+ if written > expected.size {
+ return Err(format!("{:?} is larger than the manifest says", expected.path));
+ }
+ hasher.update(&buf[..n]);
+ out.write_all(&buf[..n]).map_err(|e| io_err("Could not write", target, e))?;
+ }
+ out.sync_all().map_err(|e| io_err("Could not flush", target, e))?;
+ if written != expected.size || hex(&hasher.finalize()) != expected.sha256 {
+ return Err(format!("{:?} is damaged: its checksum does not match the manifest", expected.path));
+ }
+ Ok(())
+}
+
+fn stage_inner(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result {
+ let file = File::open(zip_path).map_err(|e| io_err("Could not open", zip_path, e))?;
+ let mut archive =
+ zip::ZipArchive::new(file).map_err(|_| "This file is not a valid backup (it is not a zip archive)".to_string())?;
+ if archive.len() > MAX_ENTRIES + 1 {
+ return Err("The backup contains too many entries".to_string());
+ }
+ let manifest = read_manifest(&mut archive)?;
+ validate_manifest(&manifest)?;
+ let expected: BTreeMap<&str, &ManifestFile> = manifest.files.iter().map(|f| (f.path.as_str(), f)).collect();
+
+ // The zip must hold exactly the manifest's files: no extras, no symlinks, no duplicates.
+ let mut index_of: BTreeMap = BTreeMap::new();
+ for i in 0..archive.len() {
+ let entry = archive.by_index_raw(i).map_err(|e| e.to_string())?;
+ let name = entry.name().to_string();
+ if entry.is_dir() {
+ continue;
+ }
+ if entry.unix_mode().is_some_and(is_symlink_mode) {
+ return Err(format!("The backup contains a symbolic link ({name:?}), which is not allowed"));
+ }
+ if name == MANIFEST_NAME {
+ continue;
+ }
+ validate_entry_path(&name)?;
+ if !expected.contains_key(name.as_str()) {
+ return Err(format!("The backup contains a file that is not in its manifest: {name:?}"));
+ }
+ if index_of.insert(name.clone(), i).is_some() {
+ return Err(format!("The backup contains {name:?} twice"));
+ }
+ }
+ if let Some(missing) = expected.keys().find(|p| !index_of.contains_key(**p)) {
+ return Err(format!("The backup is incomplete: {missing:?} is missing"));
+ }
+
+ dirs.clear_staging();
+ let result = (|| -> Result<(), String> {
+ for f in &manifest.files {
+ let first = f.path.split('/').next().unwrap_or("");
+ let target = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c));
+ extract_entry(&mut archive, index_of[&f.path], f, &target)?;
+ }
+ verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version)?;
+ // Keep the manifest beside the staged files so the apply step can re-check them.
+ let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
+ write_atomic(&dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME), &manifest_bytes)
+ })();
+ if let Err(e) = result {
+ dirs.clear_staging();
+ return Err(e);
+ }
+
+ let pending = PendingRestore {
+ backup_name: zip_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
+ backup_created_at: manifest.created_at.clone(),
+ app_version: manifest.app_version.clone(),
+ schema_version: manifest.schema_version,
+ file_count: manifest.files.len(),
+ total_bytes: manifest.files.iter().map(|f| f.size).sum(),
+ staged_at: now.to_rfc3339(),
+ };
+ let marker = serde_json::to_vec_pretty(&pending).map_err(|e| e.to_string())?;
+ if let Err(e) = write_atomic(&dirs.marker(), &marker) {
+ dirs.clear_staging();
+ return Err(e);
+ }
+ Ok(pending)
+}
+
+/// Validate and stage a backup; the swap happens on the next start (`apply_pending_restore`).
+pub fn stage_restore_impl(dirs: &DataDirs, zip_path: &Path, now: DateTime) -> Result {
+ // A previous staged restore is superseded.
+ let _ = fs::remove_file(dirs.marker());
+ stage_inner(dirs, zip_path, now)
+}
+
+pub fn cancel_pending_restore_impl(dirs: &DataDirs) -> Result<(), String> {
+ dirs.clear_staging();
+ match fs::remove_file(dirs.marker()) {
+ Ok(()) => Ok(()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(io_err("Could not remove", &dirs.marker(), e)),
+ }
+}
+
+// ---------------------------------------------------------------- apply (before the DB opens)
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum ApplyOutcome {
+ NothingPending,
+ Applied { safety_dir: PathBuf },
+ /// The old data is back in place.
+ Failed { message: String },
+}
+
+fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
+ let meta = fs::symlink_metadata(src)?;
+ if meta.is_dir() {
+ fs::create_dir_all(dst)?;
+ for entry in fs::read_dir(src)? {
+ let entry = entry?;
+ copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
+ }
+ } else if meta.is_file() {
+ fs::copy(src, dst)?;
+ }
+ Ok(())
+}
+
+/// Rename, falling back to copy-then-remove (a different volume, or a locked directory on Windows). The source
+/// is only removed after the copy succeeded, so a failure never loses data.
+fn move_path(src: &Path, dst: &Path) -> std::io::Result<()> {
+ if let Some(parent) = dst.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ if fs::rename(src, dst).is_ok() {
+ return Ok(());
+ }
+ if let Err(e) = copy_recursive(src, dst) {
+ let _ = if dst.is_dir() { fs::remove_dir_all(dst) } else { fs::remove_file(dst) };
+ return Err(e);
+ }
+ if src.is_dir() {
+ // A locked or read-only parent can leave the emptied directory itself behind; the data is at `dst`.
+ match fs::remove_dir_all(src) {
+ Ok(()) => Ok(()),
+ Err(e) => match fs::read_dir(src).map(|mut left| left.next().is_none()) {
+ Ok(true) => Ok(()),
+ _ => Err(e),
+ },
+ }
+ } else {
+ fs::remove_file(src)
+ }
+}
+
+fn unique_safety_dir(dirs: &DataDirs, now: DateTime) -> PathBuf {
+ let base = dirs.backups().join(format!("pre-restore-{}", now.format("%Y%m%d-%H%M%S")));
+ let mut candidate = base.clone();
+ let mut n = 1;
+ while candidate.exists() {
+ n += 1;
+ candidate = PathBuf::from(format!("{}-{n}", base.display()));
+ }
+ candidate
+}
+
+fn record_last_restore(dirs: &DataDirs, record: &LastRestore) {
+ if let Ok(bytes) = serde_json::to_vec_pretty(record) {
+ let _ = write_atomic(&dirs.last_restore(), &bytes);
+ }
+}
+
+/// Process a staged restore. Call after the data directories exist and before the database is opened.
+pub fn apply_pending_restore(dirs: &DataDirs, now: DateTime) -> ApplyOutcome {
+ apply_with_fault(dirs, now, None)
+}
+
+/// `fault_at` makes the n-th file move fail (tests only; production passes None) to exercise the rollback.
+fn apply_with_fault(dirs: &DataDirs, now: DateTime, fault_at: Option) -> ApplyOutcome {
+ let marker_bytes = match fs::read(dirs.marker()) {
+ Ok(b) => b,
+ Err(_) => return ApplyOutcome::NothingPending,
+ };
+ let pending: Result = serde_json::from_slice(&marker_bytes);
+ let backup_name = pending.as_ref().map(|p| p.backup_name.clone()).unwrap_or_default();
+
+ let finish_failed = |message: String, safety: Option<&Path>| {
+ let _ = fs::remove_file(dirs.marker());
+ dirs.clear_staging();
+ record_last_restore(
+ dirs,
+ &LastRestore {
+ ok: false,
+ at: now.to_rfc3339(),
+ backup_name: backup_name.clone(),
+ message: message.clone(),
+ safety_dir: safety.map(|p| p.to_string_lossy().into_owned()),
+ },
+ );
+ ApplyOutcome::Failed { message }
+ };
+
+ if pending.is_err() {
+ return finish_failed("The pending restore marker is damaged; the restore was skipped.".to_string(), None);
+ }
+
+ // Re-check the staged files (names, sizes and the DB) before touching anything.
+ let manifest: Manifest = match fs::read(dirs.staging_for(DB_ENTRY).join(MANIFEST_NAME))
+ .map_err(|e| e.to_string())
+ .and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string()))
+ {
+ Ok(m) => m,
+ Err(e) => return finish_failed(format!("The staged restore is incomplete ({e}); the restore was skipped."), None),
+ };
+ if let Err(e) = validate_manifest(&manifest) {
+ return finish_failed(format!("The staged restore is invalid: {e}"), None);
+ }
+ for f in &manifest.files {
+ let first = f.path.split('/').next().unwrap_or("");
+ let path = f.path.split('/').fold(dirs.staging_for(first), |p, c| p.join(c));
+ match fs::metadata(&path) {
+ Ok(m) if m.is_file() && m.len() == f.size => {}
+ _ => return finish_failed(format!("The staged file {:?} is missing or changed; the restore was skipped.", f.path), None),
+ }
+ }
+ if let Err(e) = verify_staged_db(&dirs.staging_for(DB_ENTRY).join(DB_ENTRY), manifest.schema_version) {
+ return finish_failed(e, None);
+ }
+
+ // Move the current state aside. Nothing is deleted.
+ let safety = unique_safety_dir(dirs, now);
+ let current: Vec<(PathBuf, PathBuf)> = {
+ let mut v = vec![
+ (dirs.data.join("voiced.db"), safety.join("voiced.db")),
+ (dirs.data.join("voiced.db-wal"), safety.join("voiced.db-wal")),
+ (dirs.data.join("voiced.db-shm"), safety.join("voiced.db-shm")),
+ ];
+ for root in ROOTS {
+ v.push((dirs.root_dir(root), safety.join(root)));
+ }
+ v
+ };
+ let mut moved_aside: Vec<&(PathBuf, PathBuf)> = Vec::new();
+ let mut installed: Vec<(PathBuf, PathBuf)> = Vec::new(); // (target, staged original)
+ let mut created_empty: Vec = Vec::new();
+ let mut step = 0usize;
+ let mut check_fault = || -> Result<(), String> {
+ step += 1;
+ if fault_at == Some(step) {
+ return Err("injected failure".to_string());
+ }
+ Ok(())
+ };
+
+ let swap = (|| -> Result<(), String> {
+ for pair in ¤t {
+ if fs::symlink_metadata(&pair.0).is_ok() {
+ check_fault()?;
+ move_path(&pair.0, &pair.1).map_err(|e| io_err("Could not move aside", &pair.0, e))?;
+ moved_aside.push(pair);
+ }
+ }
+ let staged_db = dirs.staging_for(DB_ENTRY).join(DB_ENTRY);
+ check_fault()?;
+ move_path(&staged_db, &dirs.db()).map_err(|e| io_err("Could not install", &dirs.db(), e))?;
+ installed.push((dirs.db(), staged_db));
+ for root in ROOTS {
+ let staged = dirs.staging_for(root).join(root);
+ let target = dirs.root_dir(root);
+ if staged.exists() {
+ check_fault()?;
+ move_path(&staged, &target).map_err(|e| io_err("Could not install", &target, e))?;
+ installed.push((target, staged));
+ } else {
+ fs::create_dir_all(&target).map_err(|e| io_err("Could not create", &target, e))?;
+ created_empty.push(target);
+ }
+ }
+ Ok(())
+ })();
+
+ match swap {
+ Ok(()) => {
+ let _ = fs::remove_file(dirs.marker());
+ dirs.clear_staging();
+ record_last_restore(
+ dirs,
+ &LastRestore {
+ ok: true,
+ at: now.to_rfc3339(),
+ backup_name: backup_name.clone(),
+ message: "Restored".to_string(),
+ safety_dir: Some(safety.to_string_lossy().into_owned()),
+ },
+ );
+ ApplyOutcome::Applied { safety_dir: safety }
+ }
+ Err(e) => {
+ // Roll back: installed files go back to staging, moved-aside files back to their places.
+ for dir in created_empty.iter().rev() {
+ let _ = fs::remove_dir(dir);
+ }
+ for (target, staged) in installed.iter().rev() {
+ let _ = move_path(target, staged);
+ }
+ let mut stuck = Vec::new();
+ for (orig, aside) in moved_aside.iter().rev().map(|p| (&p.0, &p.1)) {
+ if move_path(aside, orig).is_err() {
+ stuck.push(orig.display().to_string());
+ }
+ }
+ let mut message = format!("The restore failed and was rolled back: {e}");
+ if !stuck.is_empty() {
+ message.push_str(&format!(
+ ". Some files could not be put back; your previous data is in {}",
+ safety.display()
+ ));
+ }
+ finish_failed(message, Some(&safety))
+ }
+ }
+}
+
+// ---------------------------------------------------------------- automatic backup
+
+fn auto_name(day: NaiveDate) -> String {
+ format!("{AUTO_PREFIX}{}{AUTO_SUFFIX}", day.format("%Y%m%d"))
+}
+
+/// Files that are strictly `voiced-auto-YYYYMMDD.zip`, oldest first.
+fn auto_backups(dir: &Path) -> Vec<(String, PathBuf)> {
+ let mut found = Vec::new();
+ if let Ok(entries) = fs::read_dir(dir) {
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let stamp = name.strip_prefix(AUTO_PREFIX).and_then(|n| n.strip_suffix(AUTO_SUFFIX));
+ if let Some(stamp) = stamp {
+ if stamp.len() == 8 && stamp.bytes().all(|b| b.is_ascii_digit()) {
+ found.push((stamp.to_string(), entry.path()));
+ }
+ }
+ }
+ }
+ found.sort();
+ found
+}
+
+/// Keep the newest `keep` automatic backups; returns how many were deleted.
+pub fn prune_auto_backups(dir: &Path, keep: usize) -> usize {
+ let all = auto_backups(dir);
+ let excess = all.len().saturating_sub(keep);
+ all.into_iter().take(excess).filter(|(_, p)| fs::remove_file(p).is_ok()).count()
+}
+
+/// Write today's automatic backup unless it already exists, then prune. Returns the new file, if any.
+pub fn run_auto_backup(dirs: &DataDirs, now: DateTime) -> Result, String> {
+ let dir = dirs.auto_dir();
+ fs::create_dir_all(&dir).map_err(|e| io_err("Could not create", &dir, e))?;
+ // Leftovers of an interrupted run (only this job writes into this folder).
+ if let Ok(entries) = fs::read_dir(&dir) {
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().into_owned();
+ if name.ends_with(".part") || name.ends_with(".db.tmp") {
+ let _ = fs::remove_file(entry.path());
+ }
+ }
+ }
+ let target = dir.join(auto_name(now.date_naive()));
+ let created = if target.exists() {
+ None
+ } else {
+ create_backup_impl(dirs, &target, now)?;
+ Some(target)
+ };
+ prune_auto_backups(&dir, AUTO_KEEP);
+ Ok(created)
+}
+
+fn read_auto_backup_flag(db_path: &Path) -> bool {
+ Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
+ .and_then(|c| c.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0)))
+ .map(|v| v != 0)
+ .unwrap_or(true)
+}
+
+/// Start the background job: first check shortly after startup, then hourly while the app stays open.
+/// Never panics out of the thread and never blocks startup.
+pub fn spawn_auto_backup(dirs: DataDirs) {
+ if std::env::var_os("VOICED_SELFTEST_OUT").is_some() {
+ return;
+ }
+ let spawned = std::thread::Builder::new().name("auto-backup".into()).spawn(move || {
+ std::thread::sleep(std::time::Duration::from_secs(8));
+ loop {
+ if read_auto_backup_flag(&dirs.db()) {
+ let dirs = dirs.clone();
+ let outcome = std::panic::catch_unwind(move || run_auto_backup(&dirs, Local::now()));
+ match outcome {
+ Ok(Ok(Some(path))) => eprintln!("Automatic backup written to {}", path.display()),
+ Ok(Ok(None)) => {}
+ Ok(Err(e)) => eprintln!("Automatic backup failed: {e}"),
+ Err(_) => eprintln!("Automatic backup panicked"),
+ }
+ }
+ std::thread::sleep(std::time::Duration::from_secs(3600));
+ }
+ });
+ if let Err(e) = spawned {
+ eprintln!("Could not start the automatic backup thread: {e}");
+ }
+}
+
+// ---------------------------------------------------------------- status and restart
+
+pub fn backup_status_impl(dirs: &DataDirs, auto_backup: bool) -> BackupStatus {
+ let auto_dir = dirs.auto_dir();
+ let _ = fs::create_dir_all(&auto_dir); // so "open folder" always has something to open
+ let all = auto_backups(&auto_dir);
+ let last_auto_backup = all.last().and_then(|(_, p)| {
+ let modified = fs::metadata(p).and_then(|m| m.modified()).ok()?;
+ Some(DateTime::::from(modified).to_rfc3339())
+ });
+ BackupStatus {
+ auto_backup,
+ auto_dir: auto_dir.to_string_lossy().into_owned(),
+ last_auto_backup,
+ auto_backup_count: all.len(),
+ pending_restore: fs::read(dirs.marker()).ok().and_then(|b| serde_json::from_slice(&b).ok()),
+ last_restore: fs::read(dirs.last_restore()).ok().and_then(|b| serde_json::from_slice(&b).ok()),
+ }
+}
+
+/// Milliseconds a relaunched process waits before starting (set by `restart_app`), capped at 10 s.
+pub fn restart_delay_from_env(value: Option<&str>) -> Option {
+ value.and_then(|v| v.trim().parse::().ok()).map(|ms| ms.min(10_000))
+}
+
+/// Call first thing in `run()`: honour and clear the relaunch delay.
+pub fn wait_if_relaunched() {
+ if let Ok(v) = std::env::var("VOICED_RESTART_DELAY_MS") {
+ std::env::remove_var("VOICED_RESTART_DELAY_MS");
+ if let Some(ms) = restart_delay_from_env(Some(&v)) {
+ std::thread::sleep(std::time::Duration::from_millis(ms));
+ }
+ }
+}
+
+// ---------------------------------------------------------------- Tauri commands
+
+fn auto_backup_flag(conn: &Connection) -> Result {
+ conn.query_row("SELECT auto_backup FROM app_settings WHERE id = 1", [], |r| r.get::<_, i64>(0))
+ .map(|v| v != 0)
+ .map_err(|e| e.to_string())
+}
+
+#[tauri::command]
+pub async fn create_backup(state: State<'_, AppState>, dest: String) -> Result {
+ let dirs = DataDirs::from_state(&state);
+ tauri::async_runtime::spawn_blocking(move || create_backup_impl(&dirs, Path::new(&dest), Local::now()))
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+/// Phase one of a restore: validate and stage. The app must be restarted to apply it.
+#[tauri::command]
+pub async fn restore_backup(state: State<'_, AppState>, path: String) -> Result {
+ let dirs = DataDirs::from_state(&state);
+ tauri::async_runtime::spawn_blocking(move || stage_restore_impl(&dirs, Path::new(&path), Local::now()))
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+#[tauri::command]
+pub fn cancel_pending_restore(state: State) -> Result<(), String> {
+ cancel_pending_restore_impl(&DataDirs::from_state(&state))
+}
+
+#[tauri::command]
+pub fn get_backup_status(state: State) -> Result {
+ let enabled = {
+ let conn = state.db.lock().map_err(|e| e.to_string())?;
+ auto_backup_flag(&conn)?
+ };
+ Ok(backup_status_impl(&DataDirs::from_state(&state), enabled))
+}
+
+#[tauri::command]
+pub fn set_auto_backup(state: State, enabled: bool) -> Result {
+ let conn = state.db.lock().map_err(|e| e.to_string())?;
+ conn.execute("UPDATE app_settings SET auto_backup = ?1 WHERE id = 1", [enabled as i64])
+ .map_err(|e| e.to_string())?;
+ auto_backup_flag(&conn)
+}
+
+/// Relaunch the app so a staged restore is applied. The new process waits briefly (see `wait_if_relaunched`) so
+/// the single-instance lock of this one is gone, then this process exits normally.
+#[tauri::command]
+pub fn restart_app(app: tauri::AppHandle) -> Result<(), String> {
+ let exe = std::env::current_exe().map_err(|e| format!("Could not find the application: {e}"))?;
+ std::process::Command::new(exe)
+ .args(std::env::args_os().skip(1))
+ .env("VOICED_RESTART_DELAY_MS", "1500")
+ .spawn()
+ .map_err(|e| format!("Could not restart: {e}"))?;
+ app.exit(0);
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rusqlite::params;
+ use tempfile::{tempdir, TempDir};
+
+ const PDF: &[u8] = b"%PDF-1.7 archived invoice bytes";
+ const FONT: &[u8] = b"\x00\x01\x00\x00fake-ttf-bytes";
+ const LOGO: &[u8] = b"\x89PNG fake logo";
+
+ struct Env {
+ _root: TempDir,
+ dirs: DataDirs,
+ }
+
+ /// Separate data and local dirs (like Windows) so cross-root handling is exercised.
+ fn env() -> Env {
+ let root = tempdir().unwrap();
+ let data = root.path().join("data");
+ let local = root.path().join("local");
+ fs::create_dir_all(&data).unwrap();
+ fs::create_dir_all(&local).unwrap();
+ let dirs = DataDirs::new(&data, &local);
+ Env { _root: root, dirs }
+ }
+
+ fn sha(bytes: &[u8]) -> String {
+ hex(&Sha256::digest(bytes))
+ }
+
+ /// A populated data set: client, issued invoice, payment, user_fonts row, plus asset, archive and font files.
+ fn populate(dirs: &DataDirs, tag: &str) {
+ let conn = crate::db::open(&dirs.db(), &dirs.backups()).unwrap();
+ conn.execute(
+ "INSERT INTO clients (name, address, gstin, state_code, created_at) VALUES (?1, 'Addr', '29ABCDE1234F1Z5', '29', 'now')",
+ params![format!("Client {tag}")],
+ )
+ .unwrap();
+ conn.execute(
+ "INSERT INTO invoices (number, invoice_date, client_name, total, status, created_at, updated_at, archived_pdf_sha256)
+ VALUES (?1, '2026-04-01', ?2, 1180.5, 'issued', 'now', 'now', ?3)",
+ params![format!("AP/{tag}-001"), format!("Client {tag}"), sha(PDF)],
+ )
+ .unwrap();
+ let invoice_id = conn.last_insert_rowid();
+ conn.execute(
+ "INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
+ VALUES (?1, '2026-04-20', 100000, 5000, 'upi', 'now')",
+ params![invoice_id],
+ )
+ .unwrap();
+ conn.execute(
+ "INSERT INTO user_fonts (face, family_name, full_name, weight, style, sha256, file_name, format, size, licence_ack_at, imported_at)
+ VALUES ('Now', 'Now', 'Now Regular', 400, 'normal', ?1, 'Now.ttf', 'ttf', ?2, 'now', 'now')",
+ params![sha(FONT), FONT.len() as i64],
+ )
+ .unwrap();
+ conn.pragma_update(None, "wal_checkpoint", "TRUNCATE").ok();
+ drop(conn);
+ fs::create_dir_all(dirs.root_dir("assets")).unwrap();
+ fs::write(dirs.root_dir("assets").join(format!("logo-{tag}.png")), LOGO).unwrap();
+ fs::create_dir_all(dirs.root_dir("archive")).unwrap();
+ fs::write(dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF))), PDF).unwrap();
+ fs::create_dir_all(dirs.root_dir("fonts")).unwrap();
+ fs::write(dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT))), FONT).unwrap();
+ }
+
+ type Snapshot = (Vec, Vec, Vec, Vec, i64);
+
+ fn rows(conn: &Connection, sql: &str) -> Vec {
+ let mut stmt = conn.prepare(sql).unwrap();
+ let n = stmt.column_count();
+ stmt.query_map([], |r| {
+ Ok((0..n)
+ .map(|i| format!("{:?}", r.get_ref(i).unwrap()))
+ .collect::>()
+ .join("|"))
+ })
+ .unwrap()
+ .map(|r| r.unwrap())
+ .collect()
+ }
+
+ fn snapshot(dirs: &DataDirs) -> Snapshot {
+ let conn = Connection::open_with_flags(dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
+ (
+ rows(&conn, "SELECT * FROM clients ORDER BY id"),
+ rows(&conn, "SELECT * FROM invoices ORDER BY id"),
+ rows(&conn, "SELECT * FROM payments ORDER BY id"),
+ rows(&conn, "SELECT * FROM user_fonts ORDER BY id"),
+ user_version(&conn).unwrap(),
+ )
+ }
+
+ fn now() -> DateTime {
+ Local::now()
+ }
+
+ /// Re-write a zip, letting `edit` change or drop each entry.
+ fn rewrite_zip(src: &Path, dst: &Path, mut edit: impl FnMut(&str, Vec) -> Option>) {
+ let mut input = zip::ZipArchive::new(File::open(src).unwrap()).unwrap();
+ let mut out = zip::ZipWriter::new(File::create(dst).unwrap());
+ for i in 0..input.len() {
+ let mut entry = input.by_index(i).unwrap();
+ let name = entry.name().to_string();
+ let mut bytes = Vec::new();
+ entry.read_to_end(&mut bytes).unwrap();
+ if let Some(bytes) = edit(&name, bytes) {
+ out.start_file(&name, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(&bytes).unwrap();
+ }
+ }
+ out.finish().unwrap();
+ }
+
+ /// Hand-built zip from (name, bytes) pairs plus a manifest listing `listed`.
+ fn handmade(dst: &Path, entries: &[(&str, &[u8])], listed: &[(&str, &[u8])], schema: i64) {
+ let manifest = Manifest {
+ format: BACKUP_FORMAT.into(),
+ app_version: "test".into(),
+ schema_version: schema,
+ created_at: "now".into(),
+ files: listed
+ .iter()
+ .map(|(p, b)| ManifestFile { path: p.to_string(), sha256: sha(b), size: b.len() as u64 })
+ .collect(),
+ };
+ let mut out = zip::ZipWriter::new(File::create(dst).unwrap());
+ for (name, bytes) in entries {
+ out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(bytes).unwrap();
+ }
+ out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap();
+ out.finish().unwrap();
+ }
+
+ fn assert_clean(dirs: &DataDirs) {
+ assert!(!dirs.marker().exists(), "no marker may be left behind");
+ for dir in dirs.staging_dirs() {
+ assert!(!dir.exists(), "no staging may be left behind");
+ }
+ }
+
+ #[test]
+ fn round_trip_restores_rows_and_files_byte_for_byte() {
+ let src = env();
+ populate(&src.dirs, "A");
+ let before = snapshot(&src.dirs);
+ assert_eq!(before.0.len(), 1);
+ assert_eq!(before.2.len(), 1);
+ assert_eq!(before.3.len(), 1);
+
+ let zip_path = src.dirs.backups().join("manual.zip");
+ let summary = create_backup_impl(&src.dirs, &zip_path, now()).unwrap();
+ assert_eq!(summary.schema_version, LATEST_VERSION);
+ assert_eq!(summary.file_count, 4); // db + logo + pdf + font
+ assert!(!zip_path.with_extension("zip.part").exists());
+
+ // The manifest verifies against the zip contents.
+ let mut archive = zip::ZipArchive::new(File::open(&zip_path).unwrap()).unwrap();
+ let manifest = read_manifest(&mut archive).unwrap();
+ validate_manifest(&manifest).unwrap();
+ assert_eq!(manifest.app_version, env!("CARGO_PKG_VERSION"));
+ assert_eq!(manifest.schema_version, LATEST_VERSION);
+ let pdf_entry = format!("archive/{}.pdf", sha(PDF));
+ let listed = manifest.files.iter().find(|f| f.path == pdf_entry).unwrap();
+ assert_eq!((listed.sha256.as_str(), listed.size), (sha(PDF).as_str(), PDF.len() as u64));
+
+ // A different machine state to restore over: other rows and files, to be set aside.
+ let dst = env();
+ populate(&dst.dirs, "B");
+ fs::write(dst.dirs.root_dir("assets").join("only-in-b.png"), b"b").unwrap();
+ let before_b = snapshot(&dst.dirs);
+ assert_ne!(before_b, before);
+
+ let staged = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap();
+ assert_eq!(staged.file_count, 4);
+ assert_eq!(snapshot(&dst.dirs), before_b, "staging must not touch live data");
+ assert_eq!(backup_status_impl(&dst.dirs, true).pending_restore.unwrap().file_count, 4);
+
+ let outcome = apply_pending_restore(&dst.dirs, now());
+ let ApplyOutcome::Applied { safety_dir } = outcome else { panic!("{outcome:?}") };
+ assert_clean(&dst.dirs);
+ assert_eq!(snapshot(&dst.dirs), before);
+ assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-A.png")).unwrap(), LOGO);
+ assert!(!dst.dirs.root_dir("assets").join("only-in-b.png").exists());
+ assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF);
+ assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT);
+
+ // The old state is intact in the safety copy, not deleted.
+ assert!(safety_dir.starts_with(dst.dirs.backups()));
+ assert_eq!(fs::read(safety_dir.join("assets").join("only-in-b.png")).unwrap(), b"b");
+ let aside = Connection::open_with_flags(safety_dir.join("voiced.db"), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
+ assert_eq!(rows(&aside, "SELECT * FROM clients ORDER BY id"), before_b.0);
+ let last = backup_status_impl(&dst.dirs, true).last_restore.unwrap();
+ assert!(last.ok);
+
+ // The restored DB opens normally through the app's own open path.
+ drop(crate::db::open(&dst.dirs.db(), &dst.dirs.backups()).unwrap());
+ assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending);
+ }
+
+ #[test]
+ fn restore_into_empty_dirs_works_and_taking_backup_while_open_is_consistent() {
+ let src = env();
+ populate(&src.dirs, "A");
+ // Keep a connection open (as the running app does) while backing up.
+ let live = crate::db::open(&src.dirs.db(), &src.dirs.backups()).unwrap();
+ live.execute("UPDATE clients SET name = 'Live edit'", []).unwrap();
+ let zip_path = src.dirs.backups().join("live.zip");
+ create_backup_impl(&src.dirs, &zip_path, now()).unwrap();
+ drop(live);
+
+ let dst = env();
+ stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap();
+ assert!(matches!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::Applied { .. }));
+ let conn = Connection::open_with_flags(dst.dirs.db(), OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
+ let name: String = conn.query_row("SELECT name FROM clients", [], |r| r.get(0)).unwrap();
+ assert_eq!(name, "Live edit");
+ }
+
+ #[test]
+ fn tampered_file_is_rejected_and_nothing_is_staged() {
+ let src = env();
+ populate(&src.dirs, "A");
+ let good = src.dirs.backups().join("good.zip");
+ create_backup_impl(&src.dirs, &good, now()).unwrap();
+ let bad = src.dirs.backups().join("bad.zip");
+ let mut changed = false;
+ rewrite_zip(&good, &bad, |name, mut bytes| {
+ if name.starts_with("archive/") {
+ *bytes.last_mut().unwrap() ^= 0xff; // same size, different content
+ changed = true;
+ }
+ Some(bytes)
+ });
+ assert!(changed);
+
+ let dst = env();
+ let err = stage_restore_impl(&dst.dirs, &bad, now()).unwrap_err();
+ assert!(err.contains("checksum"), "{err}");
+ assert_clean(&dst.dirs);
+ assert!(!dst.dirs.db().exists());
+
+ // A listed file that is missing, and an extra file that is not listed, are both refused.
+ let missing = src.dirs.backups().join("missing.zip");
+ rewrite_zip(&good, &missing, |name, bytes| (!name.starts_with("fonts/")).then_some(bytes));
+ assert!(stage_restore_impl(&dst.dirs, &missing, now()).unwrap_err().contains("incomplete"));
+ let extra = src.dirs.backups().join("extra.zip");
+ let mut input = zip::ZipArchive::new(File::open(&good).unwrap()).unwrap();
+ let mut out = zip::ZipWriter::new(File::create(&extra).unwrap());
+ for i in 0..input.len() {
+ let entry = input.by_index_raw(i).unwrap();
+ out.raw_copy_file(entry).unwrap();
+ }
+ out.start_file("assets/sneaky.png", zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(b"x").unwrap();
+ out.finish().unwrap();
+ assert!(stage_restore_impl(&dst.dirs, &extra, now()).unwrap_err().contains("not in its manifest"));
+ assert_clean(&dst.dirs);
+ }
+
+ #[test]
+ fn path_traversal_and_foreign_names_are_rejected() {
+ let dst = env();
+ let outside = dst.dirs.data.parent().unwrap().join("evil.txt");
+ for name in ["../evil.txt", "assets/../../evil.txt", "/tmp/evil.txt", "assets\\..\\evil.txt", "C:/evil.txt", "other/x", "assets/a:b", "assets//x"] {
+ let zip_path = dst.dirs.data.parent().unwrap().join("evil.zip");
+ handmade(&zip_path, &[(DB_ENTRY, b"x"), (name, b"x")], &[(DB_ENTRY, b"x"), (name, b"x")], LATEST_VERSION);
+ let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err();
+ assert!(err.contains("Unsafe file name"), "{name}: {err}");
+ assert!(!outside.exists());
+ assert_clean(&dst.dirs);
+ }
+ // Names only in the zip (not the manifest) are caught too.
+ let zip_path = dst.dirs.data.parent().unwrap().join("evil2.zip");
+ handmade(&zip_path, &[(DB_ENTRY, b"x"), ("../evil.txt", b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION);
+ assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err());
+ assert!(!outside.exists());
+ }
+
+ #[test]
+ fn symlink_entries_are_rejected() {
+ let dst = env();
+ let zip_path = dst.dirs.data.parent().unwrap().join("link.zip");
+ let manifest = Manifest {
+ format: BACKUP_FORMAT.into(),
+ app_version: "t".into(),
+ schema_version: LATEST_VERSION,
+ created_at: "now".into(),
+ files: vec![ManifestFile { path: "assets/link".into(), sha256: sha(b"/etc/passwd"), size: 11 }, ManifestFile { path: DB_ENTRY.into(), sha256: sha(b"x"), size: 1 }],
+ };
+ let mut out = zip::ZipWriter::new(File::create(&zip_path).unwrap());
+ out.start_file(DB_ENTRY, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(b"x").unwrap();
+ out.add_symlink("assets/link", "/etc/passwd", zip::write::SimpleFileOptions::default()).unwrap();
+ out.start_file(MANIFEST_NAME, zip::write::SimpleFileOptions::default()).unwrap();
+ out.write_all(&serde_json::to_vec(&manifest).unwrap()).unwrap();
+ out.finish().unwrap();
+ let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err();
+ assert!(err.contains("symbolic link"), "{err}");
+ assert_clean(&dst.dirs);
+ }
+
+ #[test]
+ fn newer_schema_is_rejected() {
+ let dst = env();
+ // Manifest says newer.
+ let zip_path = dst.dirs.data.parent().unwrap().join("newer.zip");
+ handmade(&zip_path, &[(DB_ENTRY, b"x")], &[(DB_ENTRY, b"x")], LATEST_VERSION + 1);
+ let err = stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap_err();
+ assert!(err.contains("newer version"), "{err}");
+ assert_clean(&dst.dirs);
+
+ // Manifest lies, the database itself is newer: caught after extraction, staging removed.
+ let src = env();
+ populate(&src.dirs, "A");
+ Connection::open(src.dirs.db())
+ .unwrap()
+ .pragma_update(None, "user_version", LATEST_VERSION + 1)
+ .unwrap();
+ let good = src.dirs.backups().join("v.zip");
+ create_backup_impl(&src.dirs, &good, now()).unwrap();
+ let lie = src.dirs.backups().join("lie.zip");
+ rewrite_zip(&good, &lie, |name, bytes| {
+ if name != MANIFEST_NAME {
+ return Some(bytes);
+ }
+ let mut m: Manifest = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(m.schema_version, LATEST_VERSION + 1);
+ m.schema_version = LATEST_VERSION;
+ Some(serde_json::to_vec(&m).unwrap())
+ });
+ let err = stage_restore_impl(&dst.dirs, &lie, now()).unwrap_err();
+ assert!(err.contains("newer version") || err.contains("does not match"), "{err}");
+ assert_clean(&dst.dirs);
+ }
+
+ #[test]
+ fn corrupt_database_fails_integrity_and_garbage_zip_is_refused() {
+ let dst = env();
+ let zip_path = dst.dirs.data.parent().unwrap().join("garbage.zip");
+ handmade(&zip_path, &[(DB_ENTRY, b"this is not sqlite")], &[(DB_ENTRY, b"this is not sqlite")], LATEST_VERSION);
+ assert!(stage_restore_impl(&dst.dirs, &zip_path, now()).is_err());
+ assert_clean(&dst.dirs);
+ let not_zip = dst.dirs.data.parent().unwrap().join("x.zip");
+ fs::write(¬_zip, b"hello").unwrap();
+ assert!(stage_restore_impl(&dst.dirs, ¬_zip, now()).unwrap_err().contains("not a valid backup"));
+ }
+
+ #[test]
+ fn damaged_staging_is_refused_and_old_data_kept() {
+ let src = env();
+ populate(&src.dirs, "A");
+ let zip_path = src.dirs.backups().join("a.zip");
+ create_backup_impl(&src.dirs, &zip_path, now()).unwrap();
+
+ let dst = env();
+ populate(&dst.dirs, "B");
+ let before_b = snapshot(&dst.dirs);
+ stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap();
+ // Sabotage a staged file after staging: the size check refuses before anything is moved.
+ let staged_pdf = dst.dirs.staging_for("archive").join("archive").join(format!("{}.pdf", sha(PDF)));
+ fs::write(&staged_pdf, b"short").unwrap();
+ let outcome = apply_pending_restore(&dst.dirs, now());
+ assert!(matches!(outcome, ApplyOutcome::Failed { .. }), "{outcome:?}");
+ assert_eq!(snapshot(&dst.dirs), before_b);
+ assert!(dst.dirs.root_dir("assets").join("logo-B.png").exists());
+ assert_clean(&dst.dirs);
+ assert!(!backup_status_impl(&dst.dirs, true).last_restore.unwrap().ok);
+ }
+
+ #[test]
+ fn a_failure_at_any_step_of_the_swap_rolls_back_to_the_old_data() {
+ let src = env();
+ populate(&src.dirs, "A");
+ let zip_path = src.dirs.backups().join("a.zip");
+ create_backup_impl(&src.dirs, &zip_path, now()).unwrap();
+
+ let mut failed_steps = 0;
+ for fault in 1..=12 {
+ let dst = env();
+ populate(&dst.dirs, "B");
+ let before_b = snapshot(&dst.dirs);
+ stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap();
+ match apply_with_fault(&dst.dirs, now(), Some(fault)) {
+ ApplyOutcome::Applied { .. } => {
+ assert!(fault > failed_steps, "steps past the last move succeed");
+ break;
+ }
+ ApplyOutcome::Failed { message } => {
+ failed_steps = fault;
+ assert!(message.contains("rolled back"), "{message}");
+ assert_eq!(snapshot(&dst.dirs), before_b, "fault {fault}");
+ assert_eq!(fs::read(dst.dirs.root_dir("assets").join("logo-B.png")).unwrap(), LOGO);
+ assert_eq!(fs::read(dst.dirs.root_dir("archive").join(format!("{}.pdf", sha(PDF)))).unwrap(), PDF);
+ assert_eq!(fs::read(dst.dirs.root_dir("fonts").join(format!("{}.ttf", sha(FONT)))).unwrap(), FONT);
+ assert!(!dst.dirs.root_dir("assets").join("logo-A.png").exists());
+ assert_clean(&dst.dirs);
+ assert!(!backup_status_impl(&dst.dirs, true).last_restore.unwrap().ok);
+ }
+ ApplyOutcome::NothingPending => panic!("marker vanished"),
+ }
+ }
+ // 5 moves aside (db, assets, archive, fonts; no wal/shm after checkpoint) + 4 installs.
+ assert!(failed_steps >= 8, "only {failed_steps} steps were exercised");
+ }
+
+ #[test]
+ fn cancel_removes_staging_and_marker() {
+ let src = env();
+ populate(&src.dirs, "A");
+ let zip_path = src.dirs.backups().join("a.zip");
+ create_backup_impl(&src.dirs, &zip_path, now()).unwrap();
+ let dst = env();
+ stage_restore_impl(&dst.dirs, &zip_path, now()).unwrap();
+ cancel_pending_restore_impl(&dst.dirs).unwrap();
+ assert_clean(&dst.dirs);
+ assert_eq!(apply_pending_restore(&dst.dirs, now()), ApplyOutcome::NothingPending);
+ }
+
+ #[test]
+ fn backup_destination_inside_the_data_folders_is_refused() {
+ let e = env();
+ populate(&e.dirs, "A");
+ let inside = e.dirs.root_dir("assets").join("b.zip");
+ assert!(create_backup_impl(&e.dirs, &inside, now()).is_err());
+ assert!(create_backup_impl(&e.dirs, Path::new("relative.zip"), now()).is_err());
+ }
+
+ #[test]
+ fn auto_retention_keeps_fourteen_and_prunes_the_oldest() {
+ let dir = tempdir().unwrap();
+ for day in 1..=20 {
+ fs::write(dir.path().join(format!("voiced-auto-202601{day:02}.zip")), b"z").unwrap();
+ }
+ fs::write(dir.path().join("notes.txt"), b"keep me").unwrap();
+ fs::write(dir.path().join("voiced-auto-bad.zip"), b"keep me too").unwrap();
+ assert_eq!(prune_auto_backups(dir.path(), AUTO_KEEP), 6);
+ let names: Vec = auto_backups(dir.path()).into_iter().map(|(s, _)| s).collect();
+ assert_eq!(names.len(), 14);
+ assert_eq!(names.first().unwrap(), "20260107");
+ assert_eq!(names.last().unwrap(), "20260120");
+ assert!(dir.path().join("notes.txt").exists());
+ assert!(dir.path().join("voiced-auto-bad.zip").exists());
+ assert_eq!(prune_auto_backups(dir.path(), AUTO_KEEP), 0);
+ }
+
+ #[test]
+ fn auto_backup_runs_once_per_day_and_prunes() {
+ let e = env();
+ populate(&e.dirs, "A");
+ let day = |d: u32| Local.with_ymd_and_hms(2026, 3, d, 10, 0, 0).unwrap();
+ use chrono::TimeZone;
+ let first = run_auto_backup(&e.dirs, day(1)).unwrap().unwrap();
+ assert!(first.ends_with("voiced-auto-20260301.zip"));
+ assert_eq!(run_auto_backup(&e.dirs, day(1)).unwrap(), None, "second run the same day does nothing");
+ for d in 2..=16 {
+ assert!(run_auto_backup(&e.dirs, day(d)).unwrap().is_some());
+ }
+ let kept = auto_backups(&e.dirs.auto_dir());
+ assert_eq!(kept.len(), AUTO_KEEP);
+ assert_eq!(kept.first().unwrap().0, "20260303");
+ // No temp leftovers, and the files are valid backups.
+ let leftovers = fs::read_dir(e.dirs.auto_dir())
+ .unwrap()
+ .flatten()
+ .filter(|f| !f.file_name().to_string_lossy().ends_with(".zip"))
+ .count();
+ assert_eq!(leftovers, 0);
+ let dst = env();
+ stage_restore_impl(&dst.dirs, &kept.last().unwrap().1, now()).unwrap();
+ let status = backup_status_impl(&e.dirs, true);
+ assert_eq!(status.auto_backup_count, AUTO_KEEP);
+ assert!(status.last_auto_backup.is_some());
+ }
+
+ #[test]
+ fn restart_delay_is_parsed_and_capped() {
+ assert_eq!(restart_delay_from_env(Some("1500")), Some(1500));
+ assert_eq!(restart_delay_from_env(Some("999999")), Some(10_000));
+ assert_eq!(restart_delay_from_env(Some("abc")), None);
+ assert_eq!(restart_delay_from_env(None), None);
+ }
+
+ #[test]
+ fn entry_paths_allow_only_data_files() {
+ for ok in ["voiced.db", "assets/logo.png", "archive/ab.pdf", "fonts/x.ttf", "assets/sub/dir/f.png"] {
+ validate_entry_path(ok).unwrap();
+ }
+ for bad in ["", "voiced.db/", "manifest.json", "backups/x", "assets", "assets/", "assets/./x", "assets/x.", "assets/ "] {
+ assert!(validate_entry_path(bad).is_err(), "{bad:?}");
+ }
+ }
+}
diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs
index fbe067a..a822be8 100644
--- a/src-tauri/src/commands/invoice.rs
+++ b/src-tauri/src/commands/invoice.rs
@@ -546,6 +546,83 @@ pub fn list_invoices_impl(conn: &Connection) -> Result, Stri
Ok(rows)
}
+/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
+/// integer paise. Joined to the list rows by `id` on the TypeScript side.
+#[derive(Debug, Clone, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InvoiceLedgerRow {
+ pub id: i64,
+ pub client_gstin: String,
+ pub place_of_supply_code: String,
+ pub doc_type: String,
+ /// Distinct non-empty HSN/SAC codes of the lines, in line order.
+ pub hsn_sac: Vec,
+ /// Subtotal less discount.
+ pub taxable_paise: i64,
+ pub cgst_paise: i64,
+ pub sgst_paise: i64,
+ pub igst_paise: i64,
+ pub reverse_charge: bool,
+}
+
+pub fn list_invoice_ledger_impl(conn: &Connection) -> Result, String> {
+ let mut hsn_by_invoice: std::collections::HashMap> = std::collections::HashMap::new();
+ {
+ let mut stmt = conn
+ .prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
+ .map_err(|e| e.to_string())?;
+ let items = stmt
+ .query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
+ .map_err(|e| e.to_string())?;
+ for item in items {
+ let (id, code) = item.map_err(|e| e.to_string())?;
+ let list = hsn_by_invoice.entry(id).or_default();
+ if !code.is_empty() && !list.contains(&code) {
+ list.push(code);
+ }
+ }
+ }
+ let mut stmt = conn
+ .prepare(
+ "SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
+ cgst_amount, sgst_amount, igst_amount, reverse_charge
+ FROM invoices ORDER BY id DESC",
+ )
+ .map_err(|e| e.to_string())?;
+ let rows = stmt
+ .query_map([], |r| {
+ let id: i64 = r.get(0)?;
+ Ok(InvoiceLedgerRow {
+ id,
+ client_gstin: r.get(1)?,
+ place_of_supply_code: r.get(2)?,
+ doc_type: r.get(3)?,
+ hsn_sac: Vec::new(),
+ taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
+ cgst_paise: gst::rupees_to_paise(r.get(6)?),
+ sgst_paise: gst::rupees_to_paise(r.get(7)?),
+ igst_paise: gst::rupees_to_paise(r.get(8)?),
+ reverse_charge: r.get::<_, i64>(9)? != 0,
+ })
+ })
+ .map_err(|e| e.to_string())?
+ .collect::>>()
+ .map_err(|e| e.to_string())?;
+ Ok(rows
+ .into_iter()
+ .map(|mut row| {
+ row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
+ row
+ })
+ .collect())
+}
+
+#[tauri::command]
+pub fn list_invoice_ledger(state: State) -> Result, String> {
+ let conn = state.db.lock().map_err(|e| e.to_string())?;
+ list_invoice_ledger_impl(&conn)
+}
+
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
@@ -696,6 +773,28 @@ mod tests {
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
}
+ #[test]
+ fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
+ let mut conn = registered();
+ let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
+ conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
+ conn.execute(
+ "INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
+ params![first.id],
+ )
+ .unwrap();
+ let ledger = list_invoice_ledger_impl(&conn).unwrap();
+ assert_eq!(ledger.len(), 1);
+ let row = &ledger[0];
+ assert_eq!(row.id, first.id);
+ assert_eq!(row.place_of_supply_code, "27");
+ assert_eq!(row.doc_type, "tax_invoice");
+ assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
+ assert_eq!(row.taxable_paise, 731_000);
+ assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
+ assert!(!row.reverse_charge);
+ }
+
#[test]
fn issues_numbered_tax_invoices_with_derived_totals() {
let mut conn = registered();
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index dadeb8c..28322f1 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,5 +1,6 @@
pub mod archive;
pub mod assets;
+pub mod backup;
pub mod clients;
pub mod files;
pub mod fonts;
diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs
index 9358ffb..a4bb3b1 100644
--- a/src-tauri/src/db.rs
+++ b/src-tauri/src/db.rs
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
-const LATEST_VERSION: i64 = 8;
+pub(crate) const LATEST_VERSION: i64 = 9;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -264,6 +264,12 @@ CREATE TABLE payments (
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
"#;
+/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
+/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
+const M9: &str = r#"
+ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
+"#;
+
fn migrations() -> Migrations<'static> {
Migrations::new(vec![
M::up(SCHEMA),
@@ -274,6 +280,7 @@ fn migrations() -> Migrations<'static> {
M::up(M6),
M::up(M7),
M::up(M8),
+ M::up(M9),
])
}
@@ -294,7 +301,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result {
Ok(conn)
}
-fn has_user_tables(conn: &Connection) -> rusqlite::Result {
+/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
+/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
+pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
+ let quoted = target.to_string_lossy().replace('\'', "''");
+ conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
+}
+
+pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result {
conn.query_row(
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
[],
@@ -312,8 +326,7 @@ fn backup_before_migration(
fs::create_dir_all(backup_dir)?;
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
- let quoted = target.to_string_lossy().replace('\'', "''");
- conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
+ vacuum_into(conn, &target)?;
// A failed prune must not block startup; the backup itself already succeeded.
let _ = prune_backups(backup_dir, MAX_BACKUPS);
Ok(target)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index ea691d0..bdbe16a 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result>
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
return Err(e.into());
}
+ // A restore staged by the Data tab is swapped in now, before anything opens the database.
+ let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
+ match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
+ commands::backup::ApplyOutcome::NothingPending => {}
+ commands::backup::ApplyOutcome::Applied { safety_dir } => {
+ eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
+ }
+ commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
+ }
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
Ok(conn) => conn,
Err(e) => {
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result>
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
eprintln!("Could not derive the logo images: {e}");
}
+ commands::backup::spawn_auto_backup(dirs);
Ok(AppState {
db: Mutex::new(conn),
data_dir,
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result>
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
+ // After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
+ commands::backup::wait_if_relaunched();
+
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
// combinations. Respect an explicit user setting, otherwise turn it off.
#[cfg(target_os = "linux")]
@@ -136,6 +149,13 @@ pub fn run() {
commands::payments::record_payment,
commands::payments::list_payments,
commands::payments::delete_payment,
+ commands::backup::create_backup,
+ commands::backup::restore_backup,
+ commands::backup::cancel_pending_restore,
+ commands::backup::get_backup_status,
+ commands::backup::set_auto_backup,
+ commands::backup::restart_app,
+ commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
diff --git a/src/components/DataBackupPanel.tsx b/src/components/DataBackupPanel.tsx
new file mode 100644
index 0000000..51804c3
--- /dev/null
+++ b/src/components/DataBackupPanel.tsx
@@ -0,0 +1,278 @@
+import { useCallback, useEffect, useState } from "react";
+import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
+import { open, save } from "@tauri-apps/plugin-dialog";
+import { api } from "../lib/api";
+import {
+ backupFileName,
+ ensureZipExtension,
+ formatBytes,
+ formatWhen,
+ type BackupStatus,
+ type PendingRestore,
+} from "../lib/backup";
+import { useReturnFocus } from "../hooks/useReturnFocus";
+import { useToast } from "./ToastProvider";
+
+const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
+
+type RestoreStep = "confirm" | "staging" | "ready";
+
+const joinPath = (dir: string, name: string) =>
+ !dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
+
+/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
+export default function DataBackupPanel() {
+ const toast = useToast();
+ const [status, setStatus] = useState(null);
+ const [backingUp, setBackingUp] = useState(false);
+ const [restorePath, setRestorePath] = useState(null);
+ const [step, setStep] = useState("confirm");
+ const [staged, setStaged] = useState(null);
+ const [restoreError, setRestoreError] = useState(null);
+ const [restarting, setRestarting] = useState(false);
+ useReturnFocus(restorePath !== null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setStatus(await api.getBackupStatus());
+ } catch (e) {
+ toast.error("Could not load backup status", String(e));
+ }
+ }, [toast]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const backUpNow = async () => {
+ setBackingUp(true);
+ try {
+ let dir = "";
+ try {
+ dir = await api.getLastExportDir();
+ } catch {
+ // No remembered folder: the dialog opens wherever the system prefers.
+ }
+ const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
+ if (!chosen) return;
+ const summary = await api.createBackup(ensureZipExtension(chosen));
+ toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
+ } catch (e) {
+ toast.error("Backup failed", String(e));
+ } finally {
+ setBackingUp(false);
+ }
+ };
+
+ const chooseRestoreFile = async () => {
+ try {
+ const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
+ if (typeof picked !== "string") return;
+ setRestoreError(null);
+ setStaged(null);
+ setStep("confirm");
+ setRestorePath(picked);
+ } catch (e) {
+ toast.error("Could not open the file picker", String(e));
+ }
+ };
+
+ const closeRestore = () => {
+ if (step === "staging") return;
+ setRestorePath(null);
+ void refresh();
+ };
+
+ const stageRestore = async () => {
+ if (!restorePath) return;
+ setStep("staging");
+ setRestoreError(null);
+ try {
+ setStaged(await api.restoreBackup(restorePath));
+ setStep("ready");
+ } catch (e) {
+ setRestoreError(String(e));
+ setStep("confirm");
+ toast.error("This backup cannot be restored", String(e));
+ }
+ };
+
+ const restart = async () => {
+ setRestarting(true);
+ try {
+ await api.restartApp();
+ } catch (e) {
+ setRestarting(false);
+ toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
+ }
+ };
+
+ const cancelPending = async () => {
+ try {
+ await api.cancelPendingRestore();
+ toast.info("Restore cancelled", "Your data was not changed.");
+ } catch (e) {
+ toast.error("Could not cancel the restore", String(e));
+ }
+ setRestorePath(null);
+ await refresh();
+ };
+
+ const toggleAuto = async (enabled: boolean) => {
+ try {
+ const saved = await api.setAutoBackup(enabled);
+ setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
+ toast.success(saved ? "Automatic backup on" : "Automatic backup off");
+ } catch (e) {
+ toast.error("Could not change automatic backup", String(e));
+ await refresh();
+ }
+ };
+
+ const openFolder = async (path: string) => {
+ try {
+ await api.openFile(path);
+ } catch (e) {
+ toast.error("Could not open the folder", String(e));
+ }
+ };
+
+ const pending = status?.pendingRestore ?? null;
+
+ return (
+
+ {pending ? (
+
+ ) : null}
+ {pending ? (
+
+ void restart()} disabled={restarting}>
+ {restarting ? "Restarting…" : "Restart now"}
+
+ void cancelPending()} disabled={restarting}>
+ Cancel restore
+
+
+ ) : null}
+
+
+ Back up
+
+ Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
+ fonts. Keep a copy somewhere other than this computer.
+
+
+ void backUpNow()} disabled={backingUp}>
+ {backingUp ? "Backing up…" : "Back up now"}
+
+ {backingUp ? : null}
+
+
+
+
+ Restore from backup
+
+ Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
+ never deleted.
+
+
+ void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
+ Restore from backup…
+
+
+ {status?.lastRestore ? (
+
+ {status.lastRestore.ok
+ ? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
+ : `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
+ {status.lastRestore.safetyDir ? (
+ void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
+ Show safety copy
+
+ ) : null}
+
+ ) : null}
+
+
+
+ Automatic backup
+ void toggleAuto(checked)}
+ />
+
+ While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
+ do not need to click Save settings.
+
+
+ {status === null
+ ? "Loading…"
+ : status.lastAutoBackup
+ ? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
+ : "No automatic backup yet."}
+
+
+ status && void openFolder(status.autoDir)}>
+ Open backup folder
+
+
+
+
+ void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
+ onSecondarySubmit={closeRestore}
+ >
+
+ {restoreError ?
: null}
+ {step === "ready" && staged ? (
+ <>
+
+ {staged.backupName} passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
+ {formatWhen(staged.backupCreatedAt)}.
+
+
+ Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
+ when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
+
+ >
+ ) : (
+ <>
+
+ File: {restorePath}
+
+
+ Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
+ contents of this backup.
+
+
+ The file is checked first; a damaged or newer-version backup is refused and nothing changes.
+ Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.
+ Voiced restarts to complete the restore.
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index eb6421a..63aadd1 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -12,6 +12,8 @@ import type {
} from "./types";
import type { LogoAsset } from "./logo";
import type { Payment, PaymentInput } from "./payments";
+import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
+import type { LedgerRow } from "./historyExport";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus {
@@ -48,6 +50,7 @@ export const api = {
cancelInvoice: (id: number, reason: string) =>
invoke("cancel_invoice", { id, reason }),
listInvoices: () => invoke("list_invoices"),
+ listInvoiceLedger: () => invoke("list_invoice_ledger"),
getInvoice: (id: number) => invoke("get_invoice", { id }),
recordPayment: (input: PaymentInput) => invoke("record_payment", { input }),
@@ -102,4 +105,12 @@ export const api = {
getLastExportDir: () => invoke("get_last_export_dir"),
revealInFolder: (path: string) => invoke("reveal_in_folder", { path }),
openFile: (path: string) => invoke("open_file", { path }),
+
+ // Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
+ createBackup: (dest: string) => invoke("create_backup", { dest }),
+ restoreBackup: (path: string) => invoke("restore_backup", { path }),
+ cancelPendingRestore: () => invoke("cancel_pending_restore"),
+ getBackupStatus: () => invoke("get_backup_status"),
+ setAutoBackup: (enabled: boolean) => invoke("set_auto_backup", { enabled }),
+ restartApp: () => invoke("restart_app"),
};
diff --git a/src/lib/backup.test.ts b/src/lib/backup.test.ts
new file mode 100644
index 0000000..f2056c1
--- /dev/null
+++ b/src/lib/backup.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
+
+describe("backup helpers", () => {
+ it("names backups by local date and time", () => {
+ expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
+ });
+ it("adds the zip extension only when missing", () => {
+ expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
+ expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
+ expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
+ });
+ it("formats sizes", () => {
+ expect(formatBytes(512)).toBe("512 B");
+ expect(formatBytes(1536)).toBe("1.5 KB");
+ expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
+ expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
+ });
+ it("falls back to the raw text for an unparseable time", () => {
+ expect(formatWhen("not a date")).toBe("not a date");
+ expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
+ });
+});
diff --git a/src/lib/backup.ts b/src/lib/backup.ts
new file mode 100644
index 0000000..e5d1d6a
--- /dev/null
+++ b/src/lib/backup.ts
@@ -0,0 +1,68 @@
+/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
+export interface BackupSummary {
+ path: string;
+ createdAt: string;
+ schemaVersion: number;
+ fileCount: number;
+ totalBytes: number;
+}
+
+export interface PendingRestore {
+ backupName: string;
+ backupCreatedAt: string;
+ appVersion: string;
+ schemaVersion: number;
+ fileCount: number;
+ totalBytes: number;
+ stagedAt: string;
+}
+
+export interface LastRestore {
+ ok: boolean;
+ at: string;
+ backupName: string;
+ message: string;
+ safetyDir: string | null;
+}
+
+export interface BackupStatus {
+ autoBackup: boolean;
+ autoDir: string;
+ lastAutoBackup: string | null;
+ autoBackupCount: number;
+ pendingRestore: PendingRestore | null;
+ lastRestore: LastRestore | null;
+}
+
+const pad = (n: number) => String(n).padStart(2, "0");
+
+/** `voiced-backup-20260304-1530.zip` in local time. */
+export function backupFileName(now: Date = new Date()): string {
+ const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
+ return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
+}
+
+/** The save dialog does not always add the extension (notably on Linux). */
+export function ensureZipExtension(path: string): string {
+ return /\.zip$/i.test(path) ? path : `${path}.zip`;
+}
+
+/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ["KB", "MB", "GB", "TB"];
+ let value = bytes / 1024;
+ let i = 0;
+ while (value >= 1024 && i < units.length - 1) {
+ value /= 1024;
+ i++;
+ }
+ return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
+}
+
+/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
+export function formatWhen(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
+}
diff --git a/src/lib/historyExport.test.ts b/src/lib/historyExport.test.ts
new file mode 100644
index 0000000..729a173
--- /dev/null
+++ b/src/lib/historyExport.test.ts
@@ -0,0 +1,217 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildHistoryRows,
+ csvField,
+ exportHistory,
+ guardFormula,
+ HISTORY_COLUMNS,
+ historyExportName,
+ paiseToDecimal,
+ toCsv,
+ toJson,
+ type HistoryExportDeps,
+ type LedgerRow,
+} from "./historyExport";
+import { NO_FILTERS } from "./historyFilter";
+import type { InvoiceSummary } from "./types";
+
+const summary = (over: Partial = {}): InvoiceSummary => ({
+ id: 1,
+ number: "AP/2025-001",
+ invoiceDate: "2025-06-10",
+ dueDate: "2025-07-10",
+ clientName: "Acme, Inc.",
+ total: 0,
+ status: "issued",
+ createdAt: "",
+ poNumber: "PO-1",
+ totalPaise: 1_180_050,
+ paidPaise: 1_000_000,
+ tdsPaise: 50_000,
+ balancePaise: 130_050,
+ paymentStatus: "partially_paid",
+ ...over,
+});
+
+const ledger = (over: Partial = {}): LedgerRow => ({
+ id: 1,
+ clientGstin: "29ABCDE1234F1Z5",
+ placeOfSupplyCode: "29",
+ docType: "tax_invoice",
+ hsnSac: ["9983", "9984", "9983"],
+ taxablePaise: 1_000_000,
+ cgstPaise: 90_025,
+ sgstPaise: 90_025,
+ igstPaise: 0,
+ reverseCharge: false,
+ ...over,
+});
+
+const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
+
+describe("paiseToDecimal", () => {
+ it("formats from integer paise without float math", () => {
+ expect(paiseToDecimal(0)).toBe("0.00");
+ expect(paiseToDecimal(5)).toBe("0.05");
+ expect(paiseToDecimal(100)).toBe("1.00");
+ expect(paiseToDecimal(123_450)).toBe("1234.50");
+ expect(paiseToDecimal(-5)).toBe("-0.05");
+ expect(paiseToDecimal(-123_456)).toBe("-1234.56");
+ // 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
+ expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
+ });
+ it("rejects non-integers", () => {
+ expect(() => paiseToDecimal(1.5)).toThrow();
+ expect(() => paiseToDecimal(Number.NaN)).toThrow();
+ });
+});
+
+describe("rows", () => {
+ it("builds the CA-friendly columns", () => {
+ const [row] = buildHistoryRows([summary()], [ledger()]);
+ const get = (key: string) => row[columnIndex(key)];
+ expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
+ "Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
+ "Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
+ "Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
+ ]);
+ expect(get("invoiceNo")).toBe("AP/2025-001");
+ expect(get("placeOfSupplyCode")).toBe("29");
+ expect(get("placeOfSupply")).toBe("Karnataka");
+ expect(get("documentType")).toBe("Tax Invoice");
+ expect(get("hsnSac")).toBe("9983; 9984");
+ expect(get("taxableValue")).toBe("10000.00");
+ expect(get("cgst")).toBe("900.25");
+ expect(get("igst")).toBe("0.00");
+ expect(get("totalTax")).toBe("1800.50");
+ expect(get("invoiceTotal")).toBe("11800.50");
+ expect(get("received")).toBe("10000.00");
+ expect(get("tds")).toBe("500.00");
+ expect(get("balance")).toBe("1300.50");
+ expect(get("status")).toBe("Issued");
+ expect(get("paymentStatus")).toBe("Partly paid");
+ expect(get("cancelled")).toBe("No");
+ });
+
+ it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
+ const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
+ expect(row[columnIndex("cancelled")]).toBe("Yes");
+ expect(row[columnIndex("status")]).toBe("Cancelled");
+ expect(row[columnIndex("paymentStatus")]).toBe("");
+ expect(row[columnIndex("taxableValue")]).toBe("");
+ expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
+ });
+});
+
+describe("CSV", () => {
+ it("quotes per RFC 4180", () => {
+ expect(csvField("plain")).toBe("plain");
+ expect(csvField("a,b")).toBe('"a,b"');
+ expect(csvField('say "hi"')).toBe('"say ""hi"""');
+ expect(csvField("two\nlines")).toBe('"two\nlines"');
+ expect(csvField("cr\rhere")).toBe('"cr\rhere"');
+ expect(csvField(" padded")).toBe('" padded"');
+ });
+
+ it("guards text cells against formula injection but leaves amounts numeric", () => {
+ for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
+ expect(guardFormula(bad)).toBe(`'${bad}`);
+ }
+ expect(guardFormula("AP/2025-001")).toBe("AP/2025-001");
+ const cells = buildHistoryRows(
+ [summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
+ [ledger()],
+ );
+ const csv = toCsv(cells);
+ const dataLine = csv.split("\r\n")[1];
+ expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
+ expect(dataLine).toContain(",'-5,"); // PO number text is guarded
+ expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
+ expect(dataLine).not.toContain("'-25.00");
+ });
+
+ it("has a BOM, CRLF line ends and a trailing newline", () => {
+ const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "AP/2025-002" })], [ledger(), ledger({ id: 2 })]));
+ expect(csv.startsWith("Invoice No,Date,")).toBe(true);
+ expect(csv.endsWith("\r\n")).toBe(true);
+ expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
+ expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
+ expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
+ });
+
+ it("an empty set is a header-only file", () => {
+ const csv = toCsv(buildHistoryRows([], []));
+ expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
+ });
+});
+
+describe("JSON", () => {
+ it("wraps the rows with schema, time and filters", () => {
+ const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
+ const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
+ expect(parsed.schema).toBe("voiced.history.v1");
+ expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
+ expect(parsed.filters).toEqual(filters);
+ expect(parsed.count).toBe(1);
+ expect(parsed.rows[0]).toMatchObject({ invoiceNo: "AP/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
+ });
+
+ it("an empty set has no rows", () => {
+ const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
+ expect(parsed.rows).toEqual([]);
+ expect(parsed.count).toBe(0);
+ });
+});
+
+describe("historyExportName", () => {
+ it("uses the financial year when one is picked", () => {
+ expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
+ expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
+ });
+ it("otherwise the date range of the rows", () => {
+ const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
+ expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
+ expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
+ expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
+ });
+});
+
+describe("exportHistory", () => {
+ const make = (chosen: string | null) => {
+ const written: { path: string; text: string }[] = [];
+ const saves: { defaultPath: string; format: string }[] = [];
+ const deps: HistoryExportDeps = {
+ listLedger: async () => [ledger()],
+ getLastExportDir: async () => "/home/a/Exports",
+ save: async (o) => {
+ saves.push(o);
+ return chosen;
+ },
+ writeExportFile: async (path, bytes) => {
+ written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
+ return path;
+ },
+ now: () => new Date("2026-01-02T03:04:05Z"),
+ };
+ return { deps, written, saves };
+ };
+
+ it("writes exactly the rows given, through the save dialog", async () => {
+ const { deps, written, saves } = make("/home/a/Exports/out.csv");
+ const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
+ expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
+ expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
+ expect(written).toHaveLength(1);
+ expect(written[0].text.startsWith("Invoice No")).toBe(true);
+ expect(written[0].text.split("\r\n")).toHaveLength(3);
+ });
+
+ it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
+ const cancelled = make(null);
+ expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
+ expect(cancelled.written).toEqual([]);
+ const empty = make("/x/e.json");
+ expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
+ expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
+ });
+});
diff --git a/src/lib/historyExport.ts b/src/lib/historyExport.ts
new file mode 100644
index 0000000..bc82715
--- /dev/null
+++ b/src/lib/historyExport.ts
@@ -0,0 +1,183 @@
+import { fyLabel } from "./fiscal";
+import type { HistoryFilters } from "./historyFilter";
+import { INDIAN_STATES, type InvoiceSummary } from "./types";
+
+/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
+export interface LedgerRow {
+ id: number;
+ clientGstin: string;
+ placeOfSupplyCode: string;
+ docType: string;
+ hsnSac: string[];
+ taxablePaise: number;
+ cgstPaise: number;
+ sgstPaise: number;
+ igstPaise: number;
+ reverseCharge: boolean;
+}
+
+export const HISTORY_SCHEMA = "voiced.history.v1";
+
+/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
+export function paiseToDecimal(paise: number): string {
+ if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
+ const abs = Math.abs(paise);
+ const whole = Math.floor(abs / 100);
+ const frac = abs % 100;
+ return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
+}
+
+/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
+type Kind = "text" | "amount";
+
+interface Column {
+ key: string;
+ header: string;
+ kind: Kind;
+ value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
+}
+
+const DOC_TYPES: Record = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
+const PAYMENT_STATUS: Record = {
+ unpaid: "Unpaid",
+ partially_paid: "Partly paid",
+ paid: "Paid",
+ overdue: "Overdue",
+ none: "",
+};
+const STATUS: Record = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
+
+/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
+const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
+ l ? paiseToDecimal(pick(l)) : "";
+
+/**
+ * Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
+ * keeps its number); filter on the Cancelled column to leave them out of a sum.
+ */
+export const HISTORY_COLUMNS: readonly Column[] = [
+ { key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
+ { key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
+ { key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
+ { key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
+ { key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
+ { key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
+ {
+ key: "placeOfSupply",
+ header: "Place of Supply",
+ kind: "text",
+ value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
+ },
+ { key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
+ { key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
+ { key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
+ { key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
+ { key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
+ { key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
+ { key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
+ { key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
+ { key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
+ { key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
+ { key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
+ { key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
+ { key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
+ { key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
+ { key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
+];
+
+/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
+export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
+ const byId = new Map(ledger.map((l) => [l.id, l]));
+ return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
+}
+
+/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
+export function guardFormula(text: string): string {
+ return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
+}
+
+/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
+export function csvField(text: string): string {
+ return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+}
+
+/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
+export function toCsv(cells: string[][]): string {
+ const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
+ for (const row of cells) {
+ lines.push(
+ row.map((value, i) => {
+ const column = HISTORY_COLUMNS[i];
+ return csvField(column.kind === "text" ? guardFormula(value) : value);
+ }),
+ );
+ }
+ return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
+}
+
+/**
+ * `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
+ * 2-decimal rupee strings as in the CSV (no binary floats anywhere).
+ */
+export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
+ const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
+ return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
+}
+
+const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
+
+/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
+export function historyExportName(
+ filters: HistoryFilters,
+ summaries: InvoiceSummary[],
+ ext: "csv" | "json",
+): string {
+ let part: string;
+ if (filters.fy !== "all") {
+ part = fyLabel(filters.fy).replace(/^FY /, "FY");
+ if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
+ } else {
+ const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
+ if (dates.length === 0) part = "all";
+ else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
+ }
+ return `voiced-history-${safeName(part)}.${ext}`;
+}
+
+export type HistoryFormat = "csv" | "json";
+
+export interface HistoryExportDeps {
+ listLedger(): Promise;
+ getLastExportDir(): Promise;
+ save(opts: { defaultPath: string; format: HistoryFormat }): Promise;
+ writeExportFile(path: string, bytes: Uint8Array): Promise;
+ now(): Date;
+}
+
+function joinPath(dir: string, name: string): string {
+ if (!dir) return name;
+ if (/[\\/]$/.test(dir)) return `${dir}${name}`;
+ return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
+}
+
+/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
+export async function exportHistory(
+ deps: HistoryExportDeps,
+ summaries: InvoiceSummary[],
+ filters: HistoryFilters,
+ format: HistoryFormat,
+): Promise<{ path: string; count: number } | null> {
+ const ledger = await deps.listLedger();
+ const cells = buildHistoryRows(summaries, ledger);
+ const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
+ let dir = "";
+ try {
+ dir = await deps.getLastExportDir();
+ } catch {
+ // No remembered folder: the dialog opens wherever the system prefers.
+ }
+ const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
+ if (!chosen) return null;
+ const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
+ return { path, count: cells.length };
+}
diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss
index a363d32..7d77d65 100644
--- a/src/styles/carbon.scss
+++ b/src/styles/carbon.scss
@@ -541,6 +541,26 @@ body {
font-size: 0.75rem;
}
+/* ---------- Settings: Data tab ---------- */
+.voiced-section-title {
+ font-size: 1.25rem;
+ font-weight: 400;
+ margin-bottom: 0.5rem;
+}
+
+.voiced-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+.voiced-list {
+ margin-left: 1.25rem;
+ list-style: disc;
+}
+
/* ---------- Invoice history and detail ---------- */
.voiced-num {
text-align: right;
@@ -578,6 +598,14 @@ body {
margin-bottom: 1rem;
}
+.voiced-history__export {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
.voiced-history__row {
cursor: pointer;
}
diff --git a/src/views/AppSettings.tsx b/src/views/AppSettings.tsx
index e91b204..34f2cf9 100644
--- a/src/views/AppSettings.tsx
+++ b/src/views/AppSettings.tsx
@@ -39,6 +39,7 @@ import {
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding";
+import DataBackupPanel from "../components/DataBackupPanel";
import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
@@ -513,9 +514,7 @@ export default function AppSettings({
-
- Backup and export arrive in the next step.
-
+
diff --git a/src/views/InvoiceHistory.tsx b/src/views/InvoiceHistory.tsx
index 0fc7175..44af4ce 100644
--- a/src/views/InvoiceHistory.tsx
+++ b/src/views/InvoiceHistory.tsx
@@ -19,7 +19,9 @@ import {
Tag,
Tile,
} from "@carbon/react";
+import { save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
+import { exportHistory, type HistoryFormat } from "../lib/historyExport";
import { formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { statusBadge } from "../lib/payments";
@@ -73,6 +75,7 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
const exp = useInvoiceExport({ settings, bank: null });
+ const [exportingHistory, setExportingHistory] = useState(false);
const load = async () => {
try {
@@ -123,6 +126,34 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
}
};
+ /** Exactly the rows the table is filtered to (every page, in the current sort order). */
+ const onExportHistory = async (format: HistoryFormat) => {
+ setExportingHistory(true);
+ try {
+ const done = await exportHistory(
+ {
+ listLedger: api.listInvoiceLedger,
+ getLastExportDir: api.getLastExportDir,
+ save: ({ defaultPath, format: fmt }) =>
+ save({
+ defaultPath,
+ filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
+ }),
+ writeExportFile: api.writeExportFile,
+ now: () => new Date(),
+ },
+ sorted,
+ filters,
+ format,
+ );
+ if (done) toast.success(`Exported ${done.count} invoices`, done.path);
+ } catch (e) {
+ toast.error("Could not export the invoices", String(e));
+ } finally {
+ setExportingHistory(false);
+ }
+ };
+
return (
Invoices
@@ -216,6 +247,18 @@ export default function InvoiceHistory({ settings, active, onOpen }: Props) {
) : null}
+
+ void onExportHistory("csv")}>
+ Export CSV
+
+ void onExportHistory("json")}>
+ Export JSON
+
+
+ {filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
+
+
+
{sorted.length === 0 ? (
No invoices match these filters.
) : (