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(null); const [backingUp, setBackingUp] = useState(false); const [restorePath, setRestorePath] = useState(null); const [step, setStep] = useState("confirm"); const [staged, setStaged] = useState(null); const [restoreError, setRestoreError] = useState(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 (
{pending ? ( ) : null} {pending ? (
) : null}

Back up

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.

{backingUp ? : null}

Restore from backup

Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first, never deleted.

{status?.lastRestore ? (

{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 ? ( ) : null}

) : null}

Automatic backup

void toggleAuto(checked)} />

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.

{status === null ? "Loading…" : status.lastAutoBackup ? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).` : "No automatic backup yet."}

void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)} onSecondarySubmit={closeRestore} >
{restoreError ? : null} {step === "ready" && staged ? ( <>

{staged.backupName} passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "} {formatWhen(staged.backupCreatedAt)}.

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

) : ( <>

File: {restorePath}

Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the contents of this backup.

  • The file is checked first; a damaged or newer-version backup is refused and nothing changes.
  • Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.
  • Voiced restarts to complete the restore.
)}
); }