1import { createMemo, createSignal, Show } from "solid-js";2import { sendMessage } from "../App";3import type { VaultEntry } from "../../shared/types";4import { exportEntriesToPeachJson } from "../../shared/backup";5import { CJKExportView } from "./CJKExport";67interface ExportProps {8 onBack: () => void;9 onRequiresUnlock?: () => void;10}1112const MIN_EXPORT_PASSWORD = 12;1314export function ExportView(props: ExportProps) {15 const [showCjkExport, setShowCjkExport] = createSignal(false);16 return (17 <Show when={showCjkExport()} fallback={<MainExportView onBack={props.onBack} onOpenCjk={() => setShowCjkExport(true)} />}>18 <CJKExportView19 onBack={() => setShowCjkExport(false)}20 onRequiresUnlock={props.onRequiresUnlock}21 />22 </Show>23 );24}2526function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {27 const [exportPassword, setExportPassword] = createSignal("");28 const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");29 const [exporting, setExporting] = createSignal(false);30 const [result, setResult] = createSignal<string | null>(null);31 const [error, setError] = createSignal<string | null>(null);32 const [showCsvWarning, setShowCsvWarning] = createSignal(false);3334 const passwordsMatch = createMemo(35 () => exportPassword().length >= MIN_EXPORT_PASSWORD36 && exportPassword() === exportPasswordConfirm()37 );3839 async function handleEncryptedExport() {40 setError(null);41 setResult(null);4243 if (!passwordsMatch()) {44 setError("Passwords do not match or are too short.");45 return;46 }4748 setExporting(true);4950 const response = await sendMessage({ type: "GET_ENTRIES" });51 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5253 if (entries.length === 0) {54 setError("Your vault has no entries to export.");55 setExporting(false);56 return;57 }5859 const trashedCount = entries.filter((e) => !!e.deletedAt).length;60 const activeCount = entries.length - trashedCount;61 const countStr = trashedCount > 062 ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`63 : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6465 try {66 const blob = await exportEntriesToPeachJson(entries, exportPassword());67 const filename = `peach-backup-${dateStamp()}.json`;68 downloadBlob(69 JSON.stringify(blob, null, 2),70 "application/json",71 filename72 );73 setResult(74 `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`75 );76 setExportPassword("");77 setExportPasswordConfirm("");78 } catch (err) {79 setError(err instanceof Error ? err.message : "Export failed.");80 } finally {81 setExporting(false);82 }83 }8485 async function handleCSVExport() {86 setShowCsvWarning(false);87 setExporting(true);88 setResult(null);89 setError(null);9091 const response = await sendMessage({ type: "GET_ENTRIES" });92 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];93 const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");94 const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;9596 if (active.length === 0) {97 setError("Your vault has no entries to export.");98 setExporting(false);99 return;100 }101102 const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];103 const rows = active.map((e) => {104 const type = e.type === "note" ? "note" : "login";105 return [106 type,107 e.title,108 e.url ?? "",109 e.username ?? "",110 e.password ?? "",111 e.totp ?? "",112 Array.isArray(e.tags) ? e.tags.join("|") : "",113 e.notes ?? "",114 ]115 116 117 118 .map((v) =>119 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`120 )121 .join(",");122 });123 const csv = [headers.join(","), ...rows].join("\n");124125 const filename = `peach-passwords-${dateStamp()}.csv`;126 try {127 downloadBlob(csv, "text/csv", filename);128 const suffix = excludedPasskeys > 0129 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`130 : "";131 setResult(132 `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.`133 );134 } catch (err) {135 setError(err instanceof Error ? err.message : "Export failed.");136 } finally {137 setExporting(false);138 }139 }140141 return (142 <div class="peach-overlay-view ov-enter-right" style="display:flex">143 <div class="peach-overlay-header">144 <button145 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"146 onClick={props.onBack}147 aria-label="Back"148 >149 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">150 <path d="M6 1L1 6L6 11" />151 </svg>152 </button>153 <span class="peach-overlay-title">export</span>154 <div class="peach-overlay-spacer"></div>155 </div>156157 <div class="peach-settings-body">158159 {}160 <div class="peach-settings-group peach-export-card peach-export-card--primary">161 <p class="peach-settings-label">backup phrase</p>162 <p class="peach-export-card-copy">163 Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.164 </p>165 <button166 class="peach-btn peach-btn--subtle peach-btn--sm"167 onClick={props.onOpenCjk}168 >169 Create backup phrase170 </button>171 </div>172173 {}174 <div class="peach-settings-group peach-export-card">175 <p class="peach-settings-label">encrypted file</p>176 <p class="peach-export-card-copy">177 Download a file backup. Set a password to lock it — you'll need that password to restore.178 </p>179 <div class="peach-settings-stack peach-export-password-stack">180 <input181 class="peach-settings-input"182 type="password"183 placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}184 value={exportPassword()}185 onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}186 />187 <input188 id="export-confirm-password"189 class="peach-settings-input"190 type="password"191 placeholder="confirm backup password"192 value={exportPasswordConfirm()}193 onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}194 />195 <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>196 <p class="peach-export-note" style="color:var(--peach-t2)">197 Passwords don't match198 </p>199 </Show>200 <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>201 <p class="peach-export-note" style="color:var(--peach-t2)">202 At least {MIN_EXPORT_PASSWORD} characters203 </p>204 </Show>205 </div>206 <button207 class="peach-btn peach-btn--subtle peach-btn--sm"208 onClick={() => void handleEncryptedExport()}209 disabled={exporting() || !passwordsMatch()}210 >211 {exporting() ? "saving…" : "Download backup file"}212 </button>213 </div>214215 {}216 <div class="peach-settings-group peach-export-card">217 <p class="peach-settings-label">CSV for other apps</p>218 <p class="peach-export-card-copy">219 Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.220 </p>221 <Show222 when={!showCsvWarning()}223 fallback={224 <div class="peach-settings-actions">225 <button226 class="peach-btn peach-btn--ghost peach-btn--sm"227 onClick={() => void handleCSVExport()}228 disabled={exporting()}229 >230 {exporting() ? "saving…" : "Download CSV"}231 </button>232 <button233 class="peach-btn peach-btn--ghost peach-btn--sm"234 onClick={() => setShowCsvWarning(false)}235 >236 cancel237 </button>238 </div>239 }240 >241 <button242 class="peach-btn peach-btn--ghost peach-btn--sm"243 onClick={() => setShowCsvWarning(true)}244 >245 Export as CSV246 </button>247 </Show>248 </div>249250 <Show when={error()}>251 <div class="peach-settings-group">252 <div class="peach-restore-error">{error()}</div>253 </div>254 </Show>255256 <Show when={result()}>257 <div class="peach-settings-group">258 <div class="peach-export-success">259 <span class="peach-export-success-check">✓</span>260 <span>{result()}</span>261 </div>262 </div>263 </Show>264 </div>265 </div>266 );267}268269function dateStamp(): string {270 return new Date().toISOString().split("T")[0];271}272273function downloadBlob(content: string, mimeType: string, filename: string): void {274 try {275 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));276 const a = document.createElement("a");277 a.href = url;278 a.download = filename;279 document.body.appendChild(a);280 a.click();281 document.body.removeChild(a);282 URL.revokeObjectURL(url);283 } catch (err) {284 console.error("[Peach] Download failed:", err);285 throw err;286 }287}