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..ea52fdd --- /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!("INV/{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 98b5078..3d2321f 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 b8d232d..4ecc00b 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 ? ( +
+ + +
+ ) : 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. +

+
+ + {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. +

+
+ +
+ {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 ? ( + + ) : 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."} +

+
+ +
+
+ + 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..2737ce9 --- /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: "INV/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("INV/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("INV/2025-001")).toBe("INV/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: "INV/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: "INV/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}
+
+ + + + {filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant + +
+ {sorted.length === 0 ? (
No invoices match these filters.
) : (