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 [exporting, setExporting] = createSignal(false);29 const [result, setResult] = createSignal<string | null>(null);30 const [error, setError] = createSignal<string | null>(null);31 const [showCsvWarning, setShowCsvWarning] = createSignal(false);3233 const passwordIsValid = createMemo(34 () => exportPassword().length >= MIN_EXPORT_PASSWORD35 );3637 async function handleEncryptedExport() {38 setError(null);39 setResult(null);4041 if (exportPassword().length < MIN_EXPORT_PASSWORD) {42 setError(`Export password must be at least ${MIN_EXPORT_PASSWORD} characters.`);43 return;44 }4546 setExporting(true);4748 const response = await sendMessage({ type: "GET_ENTRIES" });49 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];5051 if (entries.length === 0) {52 setError("Your vault has no entries to export.");53 setExporting(false);54 return;55 }5657 const trashedCount = entries.filter((e) => !!e.deletedAt).length;58 const activeCount = entries.length - trashedCount;59 const countStr = trashedCount > 060 ? `${activeCount} entr${activeCount === 1 ? "y" : "ies"} · ${trashedCount} in trash`61 : `${entries.length} entr${entries.length === 1 ? "y" : "ies"}`;6263 try {64 const blob = await exportEntriesToPeachJson(entries, exportPassword());65 const filename = `peach-backup-${dateStamp()}.json`;66 downloadBlob(67 JSON.stringify(blob, null, 2),68 "application/json",69 filename70 );71 setResult(72 `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`73 );74 setExportPassword("");75 } catch (err) {76 setError(err instanceof Error ? err.message : "Export failed.");77 } finally {78 setExporting(false);79 }80 }8182 async function handleCSVExport() {83 setShowCsvWarning(false);84 setExporting(true);85 setResult(null);86 setError(null);8788 const response = await sendMessage({ type: "GET_ENTRIES" });89 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];90 const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");91 const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;9293 if (active.length === 0) {94 setError("Your vault has no entries to export.");95 setExporting(false);96 return;97 }9899 const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];100 const rows = active.map((e) => {101 const type = e.type === "note" ? "note" : "login";102 return [103 type,104 e.title,105 e.url ?? "",106 e.username ?? "",107 e.password ?? "",108 e.totp ?? "",109 Array.isArray(e.tags) ? e.tags.join("|") : "",110 e.notes ?? "",111 ]112 113 114 115 .map((v) =>116 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`117 )118 .join(",");119 });120 const csv = [headers.join(","), ...rows].join("\n");121122 const filename = `peach-passwords-${dateStamp()}.csv`;123 try {124 downloadBlob(csv, "text/csv", filename);125 const suffix = excludedPasskeys > 0126 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`127 : "";128 setResult(129 `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.`130 );131 } catch (err) {132 setError(err instanceof Error ? err.message : "Export failed.");133 } finally {134 setExporting(false);135 }136 }137138 return (139 <div class="peach-overlay-view ov-enter-right" style="display:flex">140 <div class="peach-overlay-header">141 <button142 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"143 onClick={props.onBack}144 aria-label="Back"145 >146 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">147 <path d="M6 1L1 6L6 11" />148 </svg>149 </button>150 <span class="peach-overlay-title">export</span>151 <div class="peach-overlay-spacer"></div>152 </div>153154 <div class="peach-settings-body">155156 {}157 <div class="peach-settings-group peach-export-card peach-export-card--primary">158 <p class="peach-settings-label">backup phrase</p>159 <p class="peach-export-card-copy">160 Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.161 </p>162 <button163 class="peach-btn peach-btn--subtle peach-btn--sm"164 onClick={props.onOpenCjk}165 >166 Create backup phrase167 </button>168 </div>169170 {}171 <div class="peach-settings-group peach-export-card">172 <p class="peach-settings-label">encrypted file</p>173 <p class="peach-export-card-copy">174 Download a file backup. Set a password to lock it — you'll need that password to restore.175 </p>176 <div class="peach-settings-stack peach-export-password-stack">177 <input178 class="peach-settings-input"179 type="password"180 placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}181 value={exportPassword()}182 onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}183 />184 <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>185 <p class="peach-export-note" style="color:var(--peach-t2)">186 At least {MIN_EXPORT_PASSWORD} characters187 </p>188 </Show>189 </div>190 <button191 class="peach-btn peach-btn--subtle peach-btn--sm"192 onClick={() => void handleEncryptedExport()}193 disabled={exporting() || !passwordIsValid()}194 >195 {exporting() ? "saving…" : "Download backup file"}196 </button>197 </div>198199 {}200 <div class="peach-settings-group peach-export-card">201 <p class="peach-settings-label">CSV for other apps</p>202 <p class="peach-export-card-copy">203 Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.204 </p>205 <Show206 when={!showCsvWarning()}207 fallback={208 <div class="peach-settings-actions">209 <button210 class="peach-btn peach-btn--ghost peach-btn--sm"211 onClick={() => void handleCSVExport()}212 disabled={exporting()}213 >214 {exporting() ? "saving…" : "Download CSV"}215 </button>216 <button217 class="peach-btn peach-btn--ghost peach-btn--sm"218 onClick={() => setShowCsvWarning(false)}219 >220 cancel221 </button>222 </div>223 }224 >225 <button226 class="peach-btn peach-btn--ghost peach-btn--sm"227 onClick={() => setShowCsvWarning(true)}228 >229 Export as CSV230 </button>231 </Show>232 </div>233234 <Show when={error()}>235 <div class="peach-settings-group">236 <div class="peach-restore-error">{error()}</div>237 </div>238 </Show>239240 <Show when={result()}>241 <div class="peach-settings-group">242 <div class="peach-export-success">243 <span class="peach-export-success-check">✓</span>244 <span>{result()}</span>245 </div>246 </div>247 </Show>248 </div>249 </div>250 );251}252253function dateStamp(): string {254 return new Date().toISOString().split("T")[0];255}256257function downloadBlob(content: string, mimeType: string, filename: string): void {258 try {259 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));260 const a = document.createElement("a");261 a.href = url;262 a.download = filename;263 document.body.appendChild(a);264 a.click();265 document.body.removeChild(a);266 URL.revokeObjectURL(url);267 } catch (err) {268 console.error("[Peach] Download failed:", err);269 throw err;270 }271}