Files
Voiced/src/components/DataBackupPanel.tsx
T
xavierk 92f952b136 Add backup/restore, daily auto-backup and history CSV/JSON export (Phase E3)
- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure.
- Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup.
- Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time.
- History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command.
- Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers.

Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
2026-10-04 18:13:57 +05:30

279 lines
10 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import {
backupFileName,
ensureZipExtension,
formatBytes,
formatWhen,
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
type RestoreStep = "confirm" | "staging" | "ready";
const joinPath = (dir: string, name: string) =>
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
export default function DataBackupPanel() {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
const [backingUp, setBackingUp] = useState(false);
const [restorePath, setRestorePath] = useState<string | null>(null);
const [step, setStep] = useState<RestoreStep>("confirm");
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
} catch (e) {
toast.error("Could not load backup status", String(e));
}
}, [toast]);
useEffect(() => {
void refresh();
}, [refresh]);
const backUpNow = async () => {
setBackingUp(true);
try {
let dir = "";
try {
dir = await api.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
if (!chosen) return;
const summary = await api.createBackup(ensureZipExtension(chosen));
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
} catch (e) {
toast.error("Backup failed", String(e));
} finally {
setBackingUp(false);
}
};
const chooseRestoreFile = async () => {
try {
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
if (typeof picked !== "string") return;
setRestoreError(null);
setStaged(null);
setStep("confirm");
setRestorePath(picked);
} catch (e) {
toast.error("Could not open the file picker", String(e));
}
};
const closeRestore = () => {
if (step === "staging") return;
setRestorePath(null);
void refresh();
};
const stageRestore = async () => {
if (!restorePath) return;
setStep("staging");
setRestoreError(null);
try {
setStaged(await api.restoreBackup(restorePath));
setStep("ready");
} catch (e) {
setRestoreError(String(e));
setStep("confirm");
toast.error("This backup cannot be restored", String(e));
}
};
const restart = async () => {
setRestarting(true);
try {
await api.restartApp();
} catch (e) {
setRestarting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
}
};
const cancelPending = async () => {
try {
await api.cancelPendingRestore();
toast.info("Restore cancelled", "Your data was not changed.");
} catch (e) {
toast.error("Could not cancel the restore", String(e));
}
setRestorePath(null);
await refresh();
};
const toggleAuto = async (enabled: boolean) => {
try {
const saved = await api.setAutoBackup(enabled);
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
} catch (e) {
toast.error("Could not change automatic backup", String(e));
await refresh();
}
};
const openFolder = async (path: string) => {
try {
await api.openFile(path);
} catch (e) {
toast.error("Could not open the folder", String(e));
}
};
const pending = status?.pendingRestore ?? null;
return (
<section className="voiced-stack">
{pending ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="A restore is waiting for a restart"
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
/>
) : null}
{pending ? (
<div className="voiced-actions">
<Button size="md" onClick={() => void restart()} disabled={restarting}>
{restarting ? "Restarting…" : "Restart now"}
</Button>
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
Cancel restore
</Button>
</div>
) : null}
<Tile>
<h2 className="voiced-section-title">Back up</h2>
<p className="voiced-muted">
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
fonts. Keep a copy somewhere other than this computer.
</p>
<div className="voiced-actions">
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
{backingUp ? "Backing up…" : "Back up now"}
</Button>
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Restore from backup</h2>
<p className="voiced-muted">
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
never deleted.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
Restore from backup…
</Button>
</div>
{status?.lastRestore ? (
<p className="voiced-muted" role="status">
{status.lastRestore.ok
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
{status.lastRestore.safetyDir ? (
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
Show safety copy
</Button>
) : null}
</p>
) : null}
</Tile>
<Tile>
<h2 className="voiced-section-title">Automatic backup</h2>
<Toggle
id="autoBackup"
labelText="Back up automatically every day"
labelA="Off"
labelB="On"
toggled={status?.autoBackup ?? true}
disabled={!status}
onToggle={(checked: boolean) => void toggleAuto(checked)}
/>
<p className="voiced-muted">
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
do not need to click Save settings.
</p>
<p role="status">
{status === null
? "Loading…"
: status.lastAutoBackup
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
: "No automatic backup yet."}
</p>
<div className="voiced-actions">
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
Open backup folder
</Button>
</div>
</Tile>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
primaryButtonText={
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
}
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
primaryButtonDisabled={step === "staging" || restarting}
onRequestClose={closeRestore}
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
onSecondarySubmit={closeRestore}
>
<div className="voiced-stack">
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
{step === "ready" && staged ? (
<>
<p>
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
{formatWhen(staged.backupCreatedAt)}.
</p>
<p>
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
</p>
</>
) : (
<>
<p>
File: <strong>{restorePath}</strong>
</p>
<p>
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
contents of this backup.
</p>
<ul className="voiced-list">
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
<li>Your current data is moved to a safety copy in Voiced&rsquo;s backups folder before the swap. It is never deleted.</li>
<li>Voiced restarts to complete the restore.</li>
</ul>
</>
)}
</div>
</Modal>
</section>
);
}