Compare commits
2
Commits
release/1.1.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87fd8f364 | ||
|
|
8122129a19 |
@@ -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).
|
||||
|
||||
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").
|
||||
|
||||
## Features
|
||||
@@ -38,6 +38,7 @@ page (see "Installing").
|
||||
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
|
||||
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.
|
||||
- **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.
|
||||
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
|
||||
(see `docs/voiced-invoice-v1.md`).
|
||||
@@ -85,11 +86,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
|
||||
## Installing
|
||||
|
||||
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".
|
||||
- **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
|
||||
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 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
|
||||
|
||||
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",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "voiced",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@carbon/icons-react": "^11.89.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "voiced",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Voiced — offline invoicing desktop app by Bongbetic",
|
||||
"type": "module",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
# pure Rust and need no extra system libraries.
|
||||
#
|
||||
pkgname=voiced
|
||||
version=1.1.0
|
||||
version=1.2.0
|
||||
revision=1
|
||||
archs="x86_64*"
|
||||
hostmakedepends="rust nodejs pkg-config"
|
||||
|
||||
Generated
+1
-1
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voiced"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voiced"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
license = "Apache-2.0"
|
||||
description = "Voiced — offline invoicing desktop app by Bongbetic"
|
||||
authors = ["Bongbetic"]
|
||||
|
||||
@@ -52,11 +52,11 @@ pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
|
||||
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";
|
||||
pub(super) const DB_ENTRY: &str = "voiced.db";
|
||||
pub(super) const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
|
||||
pub(super) const MARKER_NAME: &str = "pending_restore.json";
|
||||
pub(super) const LAST_RESTORE_NAME: &str = "last_restore.json";
|
||||
pub(super) const STAGING_NAME: &str = "pending-restore";
|
||||
const AUTO_PREFIX: &str = "voiced-auto-";
|
||||
const AUTO_SUFFIX: &str = ".zip";
|
||||
|
||||
@@ -100,7 +100,7 @@ impl DataDirs {
|
||||
self.data.join(LAST_RESTORE_NAME)
|
||||
}
|
||||
/// 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 {
|
||||
"assets" => self.data.join("assets"),
|
||||
_ => self.local.join(root),
|
||||
|
||||
@@ -10,5 +10,6 @@ pub mod logo;
|
||||
pub mod payments;
|
||||
pub mod presets;
|
||||
pub mod raw;
|
||||
pub mod reset;
|
||||
pub mod series;
|
||||
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());
|
||||
}
|
||||
// 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);
|
||||
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()) {
|
||||
commands::backup::ApplyOutcome::NothingPending => {}
|
||||
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||
@@ -186,6 +194,7 @@ pub fn run() {
|
||||
commands::backup::get_backup_status,
|
||||
commands::backup::set_auto_backup,
|
||||
commands::backup::restart_app,
|
||||
commands::reset::reset_all_data,
|
||||
commands::invoice::list_invoice_ledger,
|
||||
commands::assets::import_asset,
|
||||
commands::assets::save_asset_bytes,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voiced",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"identifier": "com.bongbetic.voiced",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { api } from "../lib/api";
|
||||
import type { Settings } from "../lib/types";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type BackupStatus,
|
||||
type PendingRestore,
|
||||
} from "../lib/backup";
|
||||
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
|
||||
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||
import { useToast } from "./ToastProvider";
|
||||
|
||||
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
|
||||
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) {
|
||||
const toast = useToast();
|
||||
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 [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
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 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 () => {
|
||||
try {
|
||||
setStatus(await api.getBackupStatus());
|
||||
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
|
||||
</div>
|
||||
</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
|
||||
open={restorePath !== null}
|
||||
danger={step === "confirm"}
|
||||
|
||||
@@ -122,6 +122,8 @@ export const api = {
|
||||
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||
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.
|
||||
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