123456import { createMemo, createSignal, Show } from "solid-js";7import { sendMessage } from "../App";8import type { VaultEntry } from "../../shared/types";9import { exportEntriesToPeachJson } from "../../shared/backup";10import { CJKExportView } from "./CJKExport";1112interface ExportProps {13 onBack: () => void;14 onRequiresUnlock?: () => void;15}1617const MIN_EXPORT_PASSWORD = 12;1819export function ExportView(props: ExportProps) {20 const [showCjkExport, setShowCjkExport] = createSignal(false);21 return (22 <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>23 <CJKExportView24 onBack={() => setShowCjkExport(false)}25 onRequiresUnlock={props.onRequiresUnlock}26 />27 </Show>28 );29}3031function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {32 const [exportPassword, setExportPassword] = createSignal("");33 const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");34 const [exporting, setExporting] = createSignal(false);35 const [result, setResult] = createSignal<string | null>(null);36 const [error, setError] = createSignal<string | null>(null);37 const [showCsvWarning, setShowCsvWarning] = createSignal(false);3839 const passwordsMatch = createMemo(40 () => exportPassword().length >= MIN_EXPORT_PASSWORD41 && exportPassword() === exportPasswordConfirm()42 );4344 async function handleEncryptedExport() {45 setError(null);46 setResult(null);4748 if (!passwordsMatch()) {49 setError("Passwords do not match or are too short.");50 return;51 }5253 setExporting(true);5455 const response = await sendMessage({ type: "GET_ENTRIES" });56 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5758 if (entries.length === 0) {59 setError("Your vault has no entries to export.");60 setExporting(false);61 return;62 }6364 const trashedCount = entries.filter((e) => !!e.deletedAt).length;65 const activeCount = entries.length - trashedCount;66 const countStr = trashedCount > 067 ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`68 : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6970 try {71 const blob = await exportEntriesToPeachJson(entries, exportPassword());72 const filename = `peach-backup-${dateStamp()}.json`;73 downloadBlob(74 JSON.stringify(blob, null, 2),75 "application/json",76 filename77 );78 setResult(79 `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`80 );81 setExportPassword("");82 setExportPasswordConfirm("");83 } catch (err) {84 setError(err instanceof Error ? err.message : "Export failed.");85 } finally {86 setExporting(false);87 }88 }8990 async function handleCSVExport() {91 setShowCsvWarning(false);92 setExporting(true);93 setResult(null);94 setError(null);9596 const response = await sendMessage({ type: "GET_ENTRIES" });97 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];98 const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");99 const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;100101 if (active.length === 0) {102 setError("Your vault has no entries to export.");103 setExporting(false);104 return;105 }106107 const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];108 const rows = active.map((e) => {109 const type = e.type === "note" ? "note" : "login";110 return [111 type,112 e.title,113 e.url ?? "",114 e.username ?? "",115 e.password ?? "",116 e.totp ?? "",117 Array.isArray(e.tags) ? e.tags.join("|") : "",118 e.notes ?? "",119 ]120 121 122 123 .map((v) =>124 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`125 )126 .join(",");127 });128 const csv = [headers.join(","), ...rows].join("\n");129130 const filename = `peach-passwords-${dateStamp()}.csv`;131 try {132 downloadBlob(csv, "text/csv", filename);133 const suffix = excludedPasskeys > 0134 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`135 : "";136 setResult(137 `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.`138 );139 } catch (err) {140 setError(err instanceof Error ? err.message : "Export failed.");141 } finally {142 setExporting(false);143 }144 }145146 return (147 <div class="peach-overlay-view ov-enter-right" style="display:flex">148 <div class="peach-overlay-header">149 <button150 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"151 onClick={props.onBack}152 aria-label="Back"153 >154 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">155 <path d="M6 1L1 6L6 11" />156 </svg>157 </button>158 <span class="peach-overlay-title">export</span>159 <div class="peach-overlay-spacer"></div>160 </div>161162 <div class="peach-settings-body">163164 {}165 <div class="peach-settings-group peach-export-card peach-export-card--primary">166 <p class="peach-settings-label">backup phrase</p>167 <p class="peach-export-card-copy">168 Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.169 </p>170 <button171 class="peach-btn peach-btn--subtle peach-btn--sm"172 onClick={props.onOpenCjk}173 >174 Create backup phrase175 </button>176 </div>177178 {}179 <div class="peach-settings-group peach-export-card">180 <p class="peach-settings-label">encrypted file</p>181 <p class="peach-export-card-copy">182 Download a file backup. Set a password to lock it — you'll need that password to restore.183 </p>184 <div class="peach-settings-stack peach-export-password-stack">185 <input186 class="peach-settings-input"187 type="password"188 placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}189 value={exportPassword()}190 onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}191 />192 <input193 id="export-confirm-password"194 class="peach-settings-input"195 type="password"196 placeholder="confirm backup password"197 value={exportPasswordConfirm()}198 onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}199 />200 <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>201 <p class="peach-export-note" style="color:var(--peach-t2)">202 Passwords don't match203 </p>204 </Show>205 <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>206 <p class="peach-export-note" style="color:var(--peach-t2)">207 At least {MIN_EXPORT_PASSWORD} characters208 </p>209 </Show>210 </div>211 <button212 class="peach-btn peach-btn--subtle peach-btn--sm"213 onClick={() => void handleEncryptedExport()}214 disabled={exporting() || !passwordsMatch()}215 >216 {exporting() ? "saving…" : "Download backup file"}217 </button>218 </div>219220 {}221 <div class="peach-settings-group peach-export-card">222 <p class="peach-settings-label">CSV for other apps</p>223 <p class="peach-export-card-copy">224 Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.225 </p>226 <Show227 when={!showCsvWarning()}228 fallback={229 <div class="peach-settings-actions">230 <button231 class="peach-btn peach-btn--ghost peach-btn--sm"232 onClick={() => void handleCSVExport()}233 disabled={exporting()}234 >235 {exporting() ? "saving…" : "Download CSV"}236 </button>237 <button238 class="peach-btn peach-btn--ghost peach-btn--sm"239 onClick={() => setShowCsvWarning(false)}240 >241 cancel242 </button>243 </div>244 }245 >246 <button247 class="peach-btn peach-btn--ghost peach-btn--sm"248 onClick={() => setShowCsvWarning(true)}249 >250 Export as CSV251 </button>252 </Show>253 </div>254255 <Show when={error()}>256 <div class="peach-settings-group">257 <div class="peach-restore-error">{error()}</div>258 </div>259 </Show>260261 <Show when={result()}>262 <div class="peach-settings-group">263 <div class="peach-export-success">264 <span class="peach-export-success-check">✓</span>265 <span>{result()}</span>266 </div>267 </div>268 </Show>269 </div>270 </div>271 );272}273274function dateStamp(): string {275 return new Date().toISOString().split("T")[0];276}277278function downloadBlob(content: string, mimeType: string, filename: string): void {279 try {280 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));281 const a = document.createElement("a");282 a.href = url;283 a.download = filename;284 document.body.appendChild(a);285 a.click();286 document.body.removeChild(a);287 URL.revokeObjectURL(url);288 } catch (err) {289 console.error("[Peach] Download failed:", err);290 throw err;291 }292}