// Signal-conditioned views in this file must use , never a // signal-conditioned early return (`if (signal()) return `). Early // returns in SolidJS run once at mount and never re-execute when signals // change — the v1.4.21 CJK export regression silently stopped re-rendering // because of this. See Projects.md §4.1. import { createMemo, createSignal, Show } from "solid-js"; import { sendMessage } from "../App"; import type { VaultEntry } from "../../shared/types"; import { exportEntriesToPeachJson } from "../../shared/backup"; import { CJKExportView } from "./CJKExport"; interface ExportProps { onBack: () => void; onRequiresUnlock?: () => void; } const MIN_EXPORT_PASSWORD = 12; export function ExportView(props: ExportProps) { const [showCjkExport, setShowCjkExport] = createSignal(false); return ( setShowCjkExport(true)} />}> setShowCjkExport(false)} onRequiresUnlock={props.onRequiresUnlock} /> ); } function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) { const [exportPassword, setExportPassword] = createSignal(""); const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal(""); const [exporting, setExporting] = createSignal(false); const [result, setResult] = createSignal(null); const [error, setError] = createSignal(null); const [showCsvWarning, setShowCsvWarning] = createSignal(false); const passwordsMatch = createMemo( () => exportPassword().length >= MIN_EXPORT_PASSWORD && exportPassword() === exportPasswordConfirm() ); async function handleEncryptedExport() { setError(null); setResult(null); if (!passwordsMatch()) { setError("Passwords do not match or are too short."); return; } setExporting(true); const response = await sendMessage({ type: "GET_ENTRIES" }); const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? []; if (entries.length === 0) { setError("Your vault has no entries to export."); setExporting(false); return; } const trashedCount = entries.filter((e) => !!e.deletedAt).length; const activeCount = entries.length - trashedCount; const countStr = trashedCount > 0 ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash` : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`; try { const blob = await exportEntriesToPeachJson(entries, exportPassword()); const filename = `peach-backup-${dateStamp()}.json`; downloadBlob( JSON.stringify(blob, null, 2), "application/json", filename ); setResult( `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.` ); setExportPassword(""); setExportPasswordConfirm(""); } catch (err) { setError(err instanceof Error ? err.message : "Export failed."); } finally { setExporting(false); } } async function handleCSVExport() { setShowCsvWarning(false); setExporting(true); setResult(null); setError(null); const response = await sendMessage({ type: "GET_ENTRIES" }); const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? []; const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey"); const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length; if (active.length === 0) { setError("Your vault has no entries to export."); setExporting(false); return; } const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"]; const rows = active.map((e) => { const type = e.type === "note" ? "note" : "login"; return [ type, e.title, e.url ?? "", e.username ?? "", e.password ?? "", e.totp ?? "", Array.isArray(e.tags) ? e.tags.join("|") : "", e.notes ?? "", ] // Flatten embedded newlines before RFC 4180 quote escaping so the // CSV stays single-line-per-row for tools that do not handle quoted // multi-line cells. .map((v) => `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"` ) .join(","); }); const csv = [headers.join(","), ...rows].join("\n"); const filename = `peach-passwords-${dateStamp()}.csv`; try { downloadBlob(csv, "text/csv", filename); const suffix = excludedPasskeys > 0 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.` : ""; setResult( `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"} as CSV. Filename: ${filename}.${suffix} Store this file somewhere safe — the passwords inside are in plain text.` ); } catch (err) { setError(err instanceof Error ? err.message : "Export failed."); } finally { setExporting(false); } } return (
export
{/* ── Backup phrase ── */}

backup phrase

Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.

{/* ── Encrypted file ── */}

encrypted file

Download a file backup. Set a password to lock it — you'll need that password to restore.

setExportPassword((e.target as HTMLInputElement).value)} /> setExportPasswordConfirm((e.target as HTMLInputElement).value)} /> 0 && exportPassword() !== exportPasswordConfirm()}>

Passwords don't match

0 && exportPassword().length < MIN_EXPORT_PASSWORD}>

At least {MIN_EXPORT_PASSWORD} characters

{/* ── CSV ── */}

CSV for other apps

Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.

} >
{error()}
✓ {result()}
); } function dateStamp(): string { return new Date().toISOString().split("T")[0]; } function downloadBlob(content: string, mimeType: string, filename: string): void { try { const url = URL.createObjectURL(new Blob([content], { type: mimeType })); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error("[Peach] Download failed:", err); throw err; } }