From 8122129a19373bf40ebaa39ece4001d9559126fc Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Tue, 6 Oct 2026 11:29:52 +0530 Subject: [PATCH] Add Reset all data to Settings > Data and release 1.2.0 Reset all data stages a wipe behind a typed RESET confirmation and applies it on the next start, before the database is opened. It deletes the database, assets, PDF archive, imported fonts and every backup, then the setup wizard shows again. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 19 ++- package-lock.json | 4 +- package.json | 2 +- packaging/void/template | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/backup.rs | 12 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/reset.rs | 231 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 11 +- src-tauri/tauri.conf.json | 2 +- src/components/DataBackupPanel.tsx | 76 +++++++++- src/lib/api.ts | 2 + src/lib/reset.test.ts | 42 ++++++ src/lib/reset.ts | 18 +++ 15 files changed, 405 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/commands/reset.rs create mode 100644 src/lib/reset.test.ts create mode 100644 src/lib/reset.ts diff --git a/README.md b/README.md index 81f4c22..19d151a 100644 --- a/README.md +++ b/README.md @@ -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//`, each with its `OFL.txt`: DM Sans, diff --git a/package-lock.json b/package-lock.json index 0841645..c9ba784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 36ee5dc..26c0b5f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packaging/void/template b/packaging/void/template index 0195fa5..93a855c 100644 --- a/packaging/void/template +++ b/packaging/void/template @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2f34aec..e89fe2f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voiced" -version = "1.1.0" +version = "1.2.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c697a94..561c7cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index ac3d3e1..3ab51db 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -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), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1d6f26c..e83996e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/reset.rs b/src-tauri/src/commands/reset.rs new file mode 100644 index 0000000..8eb272e --- /dev/null +++ b/src-tauri/src/commands/reset.rs @@ -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 `/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 (`/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 = 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) -> 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()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb13a26..816fd5d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,8 +74,16 @@ fn init_state(app: &tauri::App) -> Result> ); 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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 68660e1..30756a0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/DataBackupPanel.tsx b/src/components/DataBackupPanel.tsx index 5f5490f..cd07fd9 100644 --- a/src/components/DataBackupPanel.tsx +++ b/src/components/DataBackupPanel.tsx @@ -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(null); @@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa const [staged, setStaged] = useState(null); const [restoreError, setRestoreError] = useState(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 + +

Reset all data

+

+ Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced + as if it were new. +

+
+ +
+
+ + void (isResetConfirmed(resetTyped) ? resetAll() : undefined)} + onSecondarySubmit={closeReset} + > +
+

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

+

Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.

+ ) => setResetTyped(e.target.value)} + autoComplete="off" + /> +
+
+ invoke("get_backup_status"), setAutoBackup: (enabled: boolean) => invoke("set_auto_backup", { enabled }), restartApp: () => invoke("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("reset_all_data"), // ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview. erpnextGetConfig: () => invoke("erpnext_get_config"), diff --git a/src/lib/reset.test.ts b/src/lib/reset.test.ts new file mode 100644 index 0000000..02e977f --- /dev/null +++ b/src/lib/reset.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/lib/reset.ts b/src/lib/reset.ts new file mode 100644 index 0000000..d5e02db --- /dev/null +++ b/src/lib/reset.ts @@ -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. + } +}