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 ?? [];50 const active = entries.filter((e) => !e.deletedAt);5152 if (active.length === 0) {53 setError("Your vault has no entries to export.");54 setExporting(false);55 return;56 }5758 try {59 const blob = await exportEntriesToPeachJson(active, exportPassword());60 const filename = `peach-backup-${dateStamp()}.json`;61 downloadBlob(62 JSON.stringify(blob, null, 2),63 "application/json",64 filename65 );66 setResult(67 `Exported ${active.length} entr${active.length === 1 ? "y" : "ies"}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`68 );69 setExportPassword("");70 } catch (err) {71 setError(err instanceof Error ? err.message : "Export failed.");72 } finally {73 setExporting(false);74 }75 }7677 async function handleCSVExport() {78 setShowCsvWarning(false);79 setExporting(true);80 setResult(null);81 setError(null);8283 const response = await sendMessage({ type: "GET_ENTRIES" });84 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];85 const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");86 const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;8788 if (active.length === 0) {89 setError("Your vault has no entries to export.");90 setExporting(false);91 return;92 }9394 const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];95 const rows = active.map((e) => {96 const type = e.type === "note" ? "note" : "login";97 return [98 type,99 e.title,100 e.url ?? "",101 e.username ?? "",102 e.password ?? "",103 e.totp ?? "",104 Array.isArray(e.tags) ? e.tags.join("|") : "",105 e.notes ?? "",106 ]107 108 109 110 .map((v) =>111 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`112 )113 .join(",");114 });115 const csv = [headers.join(","), ...rows].join("\n");116117 const filename = `peach-passwords-${dateStamp()}.csv`;118 try {119 downloadBlob(csv, "text/csv", filename);120 const suffix = excludedPasskeys > 0121 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`122 : "";123 setResult(124 `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.`125 );126 } catch (err) {127 setError(err instanceof Error ? err.message : "Export failed.");128 } finally {129 setExporting(false);130 }131 }132133 return (134 <div class="peach-overlay-view ov-enter-right" style="display:flex">135 <div class="peach-overlay-header">136 <button137 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"138 onClick={props.onBack}139 aria-label="Back"140 >141 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">142 <path d="M6 1L1 6L6 11" />143 </svg>144 </button>145 <span class="peach-overlay-title">export</span>146 <div class="peach-overlay-spacer"></div>147 </div>148149 <div class="peach-settings-body">150151 {}152 <div class="peach-settings-group peach-export-card peach-export-card--primary">153 <p class="peach-settings-label">backup phrase</p>154 <p class="peach-export-card-copy">155 Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.156 </p>157 <button158 class="peach-btn peach-btn--subtle peach-btn--sm"159 onClick={props.onOpenCjk}160 >161 Create backup phrase162 </button>163 </div>164165 {}166 <div class="peach-settings-group peach-export-card">167 <p class="peach-settings-label">encrypted file</p>168 <p class="peach-export-card-copy">169 Download a file backup. Set a password to lock it — you'll need that password to restore.170 </p>171 <div class="peach-settings-stack peach-export-password-stack">172 <input173 class="peach-settings-input"174 type="password"175 placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}176 value={exportPassword()}177 onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}178 />179 <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>180 <p class="peach-export-note" style="color:var(--peach-t2)">181 At least {MIN_EXPORT_PASSWORD} characters182 </p>183 </Show>184 </div>185 <button186 class="peach-btn peach-btn--subtle peach-btn--sm"187 onClick={() => void handleEncryptedExport()}188 disabled={exporting() || !passwordIsValid()}189 >190 {exporting() ? "saving…" : "Download backup file"}191 </button>192 </div>193194 {}195 <div class="peach-settings-group peach-export-card">196 <p class="peach-settings-label">CSV for other apps</p>197 <p class="peach-export-card-copy">198 Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.199 </p>200 <Show201 when={!showCsvWarning()}202 fallback={203 <div class="peach-settings-actions">204 <button205 class="peach-btn peach-btn--ghost peach-btn--sm"206 onClick={() => void handleCSVExport()}207 disabled={exporting()}208 >209 {exporting() ? "saving…" : "Download CSV"}210 </button>211 <button212 class="peach-btn peach-btn--ghost peach-btn--sm"213 onClick={() => setShowCsvWarning(false)}214 >215 cancel216 </button>217 </div>218 }219 >220 <button221 class="peach-btn peach-btn--ghost peach-btn--sm"222 onClick={() => setShowCsvWarning(true)}223 >224 Export as CSV225 </button>226 </Show>227 </div>228229 <Show when={error()}>230 <div class="peach-settings-group">231 <div class="peach-restore-error">{error()}</div>232 </div>233 </Show>234235 <Show when={result()}>236 <div class="peach-settings-group">237 <div class="peach-export-success">238 <span class="peach-export-success-check">✓</span>239 <span>{result()}</span>240 </div>241 </div>242 </Show>243 </div>244 </div>245 );246}247248function dateStamp(): string {249 return new Date().toISOString().split("T")[0];250}251252function downloadBlob(content: string, mimeType: string, filename: string): void {253 try {254 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));255 const a = document.createElement("a");256 a.href = url;257 a.download = filename;258 document.body.appendChild(a);259 a.click();260 document.body.removeChild(a);261 URL.revokeObjectURL(url);262 } catch (err) {263 console.error("[Peach] Download failed:", err);264 throw err;265 }266}