Release 1.2.0: Reset all data #3
@@ -8,7 +8,7 @@ connection. The only optional network feature is the ERPNext push, which you con
|
|||||||
|
|
||||||
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
|
||||||
|
|
||||||
Current release: **1.1.0**. Installers and the Void package are attached to the release on the project's Gitea
|
Current release: **1.2.0**. Installers and the Void package are attached to the release on the project's Gitea
|
||||||
page (see "Installing").
|
page (see "Installing").
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -38,6 +38,7 @@ page (see "Installing").
|
|||||||
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
||||||
balance is cash plus TDS against the invoice total.
|
balance is cash plus TDS against the invoice total.
|
||||||
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
|
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
|
||||||
|
- **Reset all data.** Settings, Data wipes everything and returns to first-run setup after a typed `RESET`.
|
||||||
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
|
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
|
||||||
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
||||||
(see `docs/voiced-invoice-v1.md`).
|
(see `docs/voiced-invoice-v1.md`).
|
||||||
@@ -85,11 +86,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
|
|||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
|
||||||
`v1.1.0`).
|
`v1.2.0`).
|
||||||
|
|
||||||
- **Windows 10/11:** run `Voiced_1.1.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
|
- **Windows 10/11:** run `Voiced_1.2.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
|
||||||
SmartScreen may warn: choose "More info", then "Run anyway".
|
SmartScreen may warn: choose "More info", then "Run anyway".
|
||||||
- **Void Linux:** install `voiced-1.1.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
|
- **Void Linux:** install `voiced-1.2.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
|
||||||
|
|
||||||
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
|
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
|
||||||
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
|
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
|
||||||
@@ -126,6 +127,16 @@ Settings, Data tab.
|
|||||||
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
|
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
|
||||||
backup is lost.
|
backup is lost.
|
||||||
|
|
||||||
|
## Reset all data
|
||||||
|
|
||||||
|
Settings, Data, Reset all data. This permanently deletes the database (invoices, clients, payments, business details
|
||||||
|
and settings), `assets/`, the PDF `archive/`, imported `fonts/` and the whole `backups/` folder, including automatic
|
||||||
|
backups and the `pre-restore-*` safety copies, then starts Voiced as if it were new (the setup wizard shows again).
|
||||||
|
You must type `RESET` to enable the button. The wipe is staged and applied on the next start, before the database is
|
||||||
|
opened, so the "Delete everything and restart" button restarts the app for you; if a file cannot be deleted the
|
||||||
|
wipe is retried on the next start. Backup zips you saved elsewhere are not touched. Take a backup first if you may
|
||||||
|
want the data back.
|
||||||
|
|
||||||
## Fonts and licences
|
## Fonts and licences
|
||||||
|
|
||||||
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
|
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@carbon/icons-react": "^11.89.0",
|
"@carbon/icons-react": "^11.89.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "voiced",
|
"name": "voiced",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
# pure Rust and need no extra system libraries.
|
# pure Rust and need no extra system libraries.
|
||||||
#
|
#
|
||||||
pkgname=voiced
|
pkgname=voiced
|
||||||
version=1.1.0
|
version=1.2.0
|
||||||
revision=1
|
revision=1
|
||||||
archs="x86_64*"
|
archs="x86_64*"
|
||||||
hostmakedepends="rust nodejs pkg-config"
|
hostmakedepends="rust nodejs pkg-config"
|
||||||
|
|||||||
Generated
+1
-1
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voiced"
|
name = "voiced"
|
||||||
version = "1.1.0"
|
version = "1.2.0"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||||
authors = ["Bongbetic"]
|
authors = ["Bongbetic"]
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
|
|||||||
pub const AUTO_KEEP: usize = 14;
|
pub const AUTO_KEEP: usize = 14;
|
||||||
|
|
||||||
const MANIFEST_NAME: &str = "manifest.json";
|
const MANIFEST_NAME: &str = "manifest.json";
|
||||||
const DB_ENTRY: &str = "voiced.db";
|
pub(super) const DB_ENTRY: &str = "voiced.db";
|
||||||
const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
|
pub(super) const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
|
||||||
const MARKER_NAME: &str = "pending_restore.json";
|
pub(super) const MARKER_NAME: &str = "pending_restore.json";
|
||||||
const LAST_RESTORE_NAME: &str = "last_restore.json";
|
pub(super) const LAST_RESTORE_NAME: &str = "last_restore.json";
|
||||||
const STAGING_NAME: &str = "pending-restore";
|
pub(super) const STAGING_NAME: &str = "pending-restore";
|
||||||
const AUTO_PREFIX: &str = "voiced-auto-";
|
const AUTO_PREFIX: &str = "voiced-auto-";
|
||||||
const AUTO_SUFFIX: &str = ".zip";
|
const AUTO_SUFFIX: &str = ".zip";
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ impl DataDirs {
|
|||||||
self.data.join(LAST_RESTORE_NAME)
|
self.data.join(LAST_RESTORE_NAME)
|
||||||
}
|
}
|
||||||
/// Where a root (`assets`, `archive`, `fonts`) lives.
|
/// Where a root (`assets`, `archive`, `fonts`) lives.
|
||||||
fn root_dir(&self, root: &str) -> PathBuf {
|
pub(super) fn root_dir(&self, root: &str) -> PathBuf {
|
||||||
match root {
|
match root {
|
||||||
"assets" => self.data.join("assets"),
|
"assets" => self.data.join("assets"),
|
||||||
_ => self.local.join(root),
|
_ => self.local.join(root),
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ pub mod logo;
|
|||||||
pub mod payments;
|
pub mod payments;
|
||||||
pub mod presets;
|
pub mod presets;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
|
pub mod reset;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again).
|
||||||
|
//!
|
||||||
|
//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
|
||||||
|
//! so deleting them live is unsafe. Like a restore, a reset has two phases:
|
||||||
|
//!
|
||||||
|
//! 1. `reset_all_data` (`stage_wipe_impl`) writes `<data>/pending_wipe.json` and touches nothing else.
|
||||||
|
//! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is
|
||||||
|
//! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the
|
||||||
|
//! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the
|
||||||
|
//! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging.
|
||||||
|
//!
|
||||||
|
//! This also deletes the backups (`<data>/backups`, including pre-restore safety copies and pre-migration
|
||||||
|
//! copies); that is the point of a reset.
|
||||||
|
use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME};
|
||||||
|
use super::raw::write_atomic;
|
||||||
|
use crate::AppState;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const WIPE_MARKER_NAME: &str = "pending_wipe.json";
|
||||||
|
/// The database and its SQLite sidecars, in the data dir.
|
||||||
|
const OWNED_DB_FILES: [&str; 4] = [
|
||||||
|
DB_ENTRY,
|
||||||
|
"voiced.db-wal",
|
||||||
|
"voiced.db-shm",
|
||||||
|
"voiced.db-journal",
|
||||||
|
];
|
||||||
|
/// Marker files of the restore mechanism, in the data dir.
|
||||||
|
const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME];
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum WipeOutcome {
|
||||||
|
NothingPending,
|
||||||
|
Wiped,
|
||||||
|
/// Something could not be deleted; the marker is kept so the next start retries.
|
||||||
|
Failed {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker(dirs: &DataDirs) -> std::path::PathBuf {
|
||||||
|
dirs.data.join(WIPE_MARKER_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a file or directory tree if it exists. A symlink is unlinked, never followed.
|
||||||
|
fn remove_if_exists(path: &Path) -> Result<(), String> {
|
||||||
|
let meta = match fs::symlink_metadata(path) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(e) => return Err(format!("Could not delete {}: {e}", path.display())),
|
||||||
|
};
|
||||||
|
let result = if meta.is_dir() {
|
||||||
|
fs::remove_dir_all(path)
|
||||||
|
} else {
|
||||||
|
fs::remove_file(path)
|
||||||
|
};
|
||||||
|
result.map_err(|e| format!("Could not delete {}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that the next start must wipe everything. The live data is not touched.
|
||||||
|
pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> {
|
||||||
|
write_atomic(&marker(dirs), b"{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call after the data directories exist and before anything else (restore, database) uses them.
|
||||||
|
pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome {
|
||||||
|
if fs::symlink_metadata(marker(dirs)).is_err() {
|
||||||
|
return WipeOutcome::NothingPending;
|
||||||
|
}
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
// Order matters: the database goes first so a half-finished wipe never leaves data with no settings row.
|
||||||
|
for name in OWNED_DB_FILES {
|
||||||
|
targets.push(dirs.data.join(name));
|
||||||
|
}
|
||||||
|
targets.push(dirs.backups());
|
||||||
|
for root in ROOTS {
|
||||||
|
targets.push(dirs.root_dir(root));
|
||||||
|
}
|
||||||
|
for name in OWNED_MARKERS {
|
||||||
|
targets.push(dirs.data.join(name));
|
||||||
|
}
|
||||||
|
targets.push(dirs.data.join(STAGING_NAME));
|
||||||
|
targets.push(dirs.local.join(STAGING_NAME));
|
||||||
|
|
||||||
|
let errors: Vec<String> = targets
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| remove_if_exists(p).err())
|
||||||
|
.collect();
|
||||||
|
if !errors.is_empty() {
|
||||||
|
return WipeOutcome::Failed {
|
||||||
|
message: errors.join("; "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match remove_if_exists(&marker(dirs)) {
|
||||||
|
Ok(()) => WipeOutcome::Wiped,
|
||||||
|
Err(message) => WipeOutcome::Failed { message },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase one of a reset: stage the wipe. The app must be restarted to carry it out.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_all_data(state: State<AppState>) -> Result<(), String> {
|
||||||
|
stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
fn populate(dirs: &DataDirs) {
|
||||||
|
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
|
||||||
|
fs::write(dirs.data.join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
fs::create_dir_all(dirs.backups().join("auto")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dirs.backups().join("auto").join("voiced-auto-20260101.zip"),
|
||||||
|
b"x",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for root in ROOTS {
|
||||||
|
fs::create_dir_all(dirs.root_dir(root)).unwrap();
|
||||||
|
fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap();
|
||||||
|
}
|
||||||
|
for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] {
|
||||||
|
fs::create_dir_all(&staging).unwrap();
|
||||||
|
fs::write(staging.join("manifest.json"), b"x").unwrap();
|
||||||
|
}
|
||||||
|
fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap();
|
||||||
|
fs::create_dir_all(dirs.data.join("other")).unwrap();
|
||||||
|
fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_wiped(dirs: &DataDirs) {
|
||||||
|
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
|
||||||
|
assert!(!dirs.data.join(name).exists(), "{name}");
|
||||||
|
}
|
||||||
|
assert!(!dirs.backups().exists());
|
||||||
|
for root in ROOTS {
|
||||||
|
assert!(!dirs.root_dir(root).exists(), "{root}");
|
||||||
|
}
|
||||||
|
assert!(!dirs.data.join(STAGING_NAME).exists());
|
||||||
|
assert!(!dirs.local.join(STAGING_NAME).exists());
|
||||||
|
assert!(!marker(dirs).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_unrelated_kept(dirs: &DataDirs) {
|
||||||
|
assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep");
|
||||||
|
assert!(dirs.data.join("other").join("keep.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_staged_means_nothing_is_deleted() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let dirs = DataDirs::new(tmp.path(), tmp.path());
|
||||||
|
populate(&dirs);
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
|
||||||
|
assert!(dirs.db().exists());
|
||||||
|
assert!(dirs.backups().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staging_alone_does_not_touch_live_data() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let dirs = DataDirs::new(tmp.path(), tmp.path());
|
||||||
|
populate(&dirs);
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert!(marker(&dirs).exists());
|
||||||
|
assert!(dirs.db().exists());
|
||||||
|
assert!(dirs.root_dir("archive").join("f.bin").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_removes_owned_data_and_keeps_unrelated_files() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let dirs = DataDirs::new(tmp.path(), tmp.path());
|
||||||
|
populate(&dirs);
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
assert_wiped(&dirs);
|
||||||
|
assert_unrelated_kept(&dirs);
|
||||||
|
assert!(dirs.data.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_handles_separate_data_and_local_roots() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let data = tmp.path().join("roaming");
|
||||||
|
let local = tmp.path().join("local");
|
||||||
|
fs::create_dir_all(&data).unwrap();
|
||||||
|
fs::create_dir_all(&local).unwrap();
|
||||||
|
let dirs = DataDirs::new(&data, &local);
|
||||||
|
populate(&dirs);
|
||||||
|
fs::write(local.join("unrelated-local.txt"), b"keep").unwrap();
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
assert_wiped(&dirs);
|
||||||
|
assert_unrelated_kept(&dirs);
|
||||||
|
assert!(local.join("unrelated-local.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wipe_is_idempotent_when_things_are_missing() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let dirs = DataDirs::new(tmp.path(), tmp.path());
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
|
||||||
|
populate(&dirs);
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
assert_wiped(&dirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_symlinked_root_is_unlinked_not_followed() {
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let outside = tempdir().unwrap();
|
||||||
|
fs::write(outside.path().join("precious.txt"), b"keep").unwrap();
|
||||||
|
let dirs = DataDirs::new(tmp.path(), tmp.path());
|
||||||
|
std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap();
|
||||||
|
stage_wipe_impl(&dirs).unwrap();
|
||||||
|
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
|
||||||
|
assert!(!dirs.root_dir("archive").exists());
|
||||||
|
assert!(outside.path().join("precious.txt").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -74,8 +74,16 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
);
|
);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
// A reset staged by the Data tab is carried out first (it also discards any staged restore).
|
||||||
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||||
|
match commands::reset::apply_pending_wipe(&dirs) {
|
||||||
|
commands::reset::WipeOutcome::NothingPending => {}
|
||||||
|
commands::reset::WipeOutcome::Wiped => eprintln!("All data was reset."),
|
||||||
|
commands::reset::WipeOutcome::Failed { message } => {
|
||||||
|
eprintln!("Reset failed, it will be retried on the next start: {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
commands::backup::ApplyOutcome::NothingPending => {}
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
@@ -186,6 +194,7 @@ pub fn run() {
|
|||||||
commands::backup::get_backup_status,
|
commands::backup::get_backup_status,
|
||||||
commands::backup::set_auto_backup,
|
commands::backup::set_auto_backup,
|
||||||
commands::backup::restart_app,
|
commands::backup::restart_app,
|
||||||
|
commands::reset::reset_all_data,
|
||||||
commands::invoice::list_invoice_ledger,
|
commands::invoice::list_invoice_ledger,
|
||||||
commands::assets::import_asset,
|
commands::assets::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voiced",
|
"productName": "Voiced",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"identifier": "com.bongbetic.voiced",
|
"identifier": "com.bongbetic.voiced",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
|
||||||
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { Settings } from "../lib/types";
|
import type { Settings } from "../lib/types";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type BackupStatus,
|
type BackupStatus,
|
||||||
type PendingRestore,
|
type PendingRestore,
|
||||||
} from "../lib/backup";
|
} from "../lib/backup";
|
||||||
|
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
|
||||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
import { useToast } from "./ToastProvider";
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
|
|||||||
onRerunSetup: () => void;
|
onRerunSetup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
|
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
|
||||||
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
|
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [status, setStatus] = useState<BackupStatus | null>(null);
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||||
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
|||||||
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
useReturnFocus(restorePath !== null);
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [resetTyped, setResetTyped] = useState("");
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
useReturnFocus(restorePath !== null || resetOpen);
|
||||||
|
|
||||||
const rerunSetup = async () => {
|
const rerunSetup = async () => {
|
||||||
const ok = await confirm(
|
const ok = await confirm(
|
||||||
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeReset = () => {
|
||||||
|
if (resetting) return;
|
||||||
|
setResetOpen(false);
|
||||||
|
setResetTyped("");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
|
||||||
|
const resetAll = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await api.resetAllData();
|
||||||
|
} catch (e) {
|
||||||
|
setResetting(false);
|
||||||
|
toast.error("Could not reset", String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearVoicedLocalState();
|
||||||
|
try {
|
||||||
|
await api.restartApp();
|
||||||
|
} catch (e) {
|
||||||
|
setResetting(false);
|
||||||
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setStatus(await api.getBackupStatus());
|
setStatus(await api.getBackupStatus());
|
||||||
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
|||||||
</div>
|
</div>
|
||||||
</Tile>
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Reset all data</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
|
||||||
|
as if it were new.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
|
||||||
|
Reset all data…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={resetOpen}
|
||||||
|
danger
|
||||||
|
modalHeading="Reset all data?"
|
||||||
|
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
|
||||||
|
onRequestClose={closeReset}
|
||||||
|
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
|
||||||
|
onSecondarySubmit={closeReset}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<p>
|
||||||
|
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
|
||||||
|
every backup (automatic, manual copies kept in Voiced’s folder, and safety copies) will be permanently deleted. This
|
||||||
|
cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
|
||||||
|
<TextInput
|
||||||
|
id="resetConfirm"
|
||||||
|
labelText={`Type ${RESET_WORD} to confirm`}
|
||||||
|
value={resetTyped}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={restorePath !== null}
|
open={restorePath !== null}
|
||||||
danger={step === "confirm"}
|
danger={step === "confirm"}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export const api = {
|
|||||||
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||||
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||||
restartApp: () => invoke<void>("restart_app"),
|
restartApp: () => invoke<void>("restart_app"),
|
||||||
|
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
|
||||||
|
resetAllData: () => invoke<void>("reset_all_data"),
|
||||||
|
|
||||||
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
|
||||||
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
|
||||||
|
|
||||||
|
describe("isResetConfirmed", () => {
|
||||||
|
it("accepts the word in any case with surrounding spaces", () => {
|
||||||
|
expect(isResetConfirmed("RESET")).toBe(true);
|
||||||
|
expect(isResetConfirmed(" reset ")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects anything else", () => {
|
||||||
|
expect(isResetConfirmed("")).toBe(false);
|
||||||
|
expect(isResetConfirmed("rese")).toBe(false);
|
||||||
|
expect(isResetConfirmed("reset all")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearVoicedLocalState", () => {
|
||||||
|
// The node test environment has no localStorage, so stub a minimal one.
|
||||||
|
beforeEach(() => {
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
get length() {
|
||||||
|
return data.size;
|
||||||
|
},
|
||||||
|
key: (i: number) => [...data.keys()][i] ?? null,
|
||||||
|
getItem: (k: string) => data.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => void data.set(k, v),
|
||||||
|
removeItem: (k: string) => void data.delete(k),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("removes only the app's own keys", () => {
|
||||||
|
localStorage.setItem("voiced.theme", "g100");
|
||||||
|
localStorage.setItem("voiced.settingsTab", "data");
|
||||||
|
localStorage.setItem("other.key", "keep");
|
||||||
|
clearVoicedLocalState();
|
||||||
|
expect(localStorage.getItem("voiced.theme")).toBeNull();
|
||||||
|
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
|
||||||
|
expect(localStorage.getItem("other.key")).toBe("keep");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** The word typed to enable "Reset all data". */
|
||||||
|
export const RESET_WORD = "RESET";
|
||||||
|
|
||||||
|
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
|
||||||
|
|
||||||
|
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
|
||||||
|
export function clearVoicedLocalState(): void {
|
||||||
|
try {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i += 1) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("voiced.")) keys.push(key);
|
||||||
|
}
|
||||||
|
keys.forEach((key) => localStorage.removeItem(key));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked; there is then nothing to clear.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user