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