123456import { createMemo, createSignal, Show } from "solid-js";7import { sendMessage } from "../App";8import type { PeachLicenseExport, 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 72 let license: PeachLicenseExport | undefined;73 const licenseRes = await sendMessage({ type: "GET_LICENSE" });74 const licenseInfo = (licenseRes as { ok: boolean; data?: { valid?: boolean; plan?: string; issued?: string } }).data;75 if (licenseInfo?.valid && licenseInfo.plan === "pro") {76 const keyRes = await sendMessage({ type: "GET_LICENSE_KEY" });77 const keyStr = (keyRes as { ok: boolean; data?: string }).data;78 if (keyStr) {79 license = {80 key: keyStr,81 ...(licenseInfo.issued ? { activatedAt: new Date(licenseInfo.issued).getTime() } : {}),82 };83 }84 }8586 const blob = await exportEntriesToPeachJson(entries, exportPassword(), license);87 const filename = `peach-backup-${dateStamp()}.json`;88 downloadBlob(89 JSON.stringify(blob, null, 2),90 "application/json",91 filename92 );93 setResult(94 `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`95 );96 setExportPassword("");97 setExportPasswordConfirm("");98 } catch (err) {99 setError(err instanceof Error ? err.message : "Export failed.");100 } finally {101 setExporting(false);102 }103 }104105 async function handleCSVExport() {106 setShowCsvWarning(false);107 setExporting(true);108 setResult(null);109 setError(null);110111 const response = await sendMessage({ type: "GET_ENTRIES" });112 const entries = (response as { ok: boolean; data?: VaultEntry[] }).data ?? [];113 const active = entries.filter((e) => e.deletedAt === undefined && e.type !== "passkey");114 const excludedPasskeys = entries.filter((e) => e.deletedAt === undefined && e.type === "passkey").length;115116 if (active.length === 0) {117 setError("Your vault has no entries to export.");118 setExporting(false);119 return;120 }121122 const headers = ["type", "name", "url", "username", "password", "totp", "tags", "notes"];123 const rows = active.map((e) => {124 const type = e.type === "note" ? "note" : "login";125 return [126 type,127 e.title,128 e.url ?? "",129 e.username ?? "",130 e.password ?? "",131 e.totp ?? "",132 Array.isArray(e.tags) ? e.tags.join("|") : "",133 e.notes ?? "",134 ]135 136 137 138 .map((v) =>139 `"${String(v).replace(/\r?\n/g, " ").replace(/"/g, '""')}"`140 )141 .join(",");142 });143 const csv = [headers.join(","), ...rows].join("\n");144145 const filename = `peach-passwords-${dateStamp()}.csv`;146 try {147 downloadBlob(csv, "text/csv", filename);148 const suffix = excludedPasskeys > 0149 ? ` ${excludedPasskeys} passkey${excludedPasskeys === 1 ? "" : "s"} excluded from plaintext export.`150 : "";151 setResult(152 `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.`153 );154 } catch (err) {155 setError(err instanceof Error ? err.message : "Export failed.");156 } finally {157 setExporting(false);158 }159 }160161 return (162 <div class="peach-overlay-view ov-enter-right" style="display:flex">163 <div class="peach-overlay-header">164 <button165 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"166 onClick={props.onBack}167 aria-label="Back"168 >169 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">170 <path d="M6 1L1 6L6 11" />171 </svg>172 </button>173 <span class="peach-overlay-title">export</span>174 <div class="peach-overlay-spacer"></div>175 </div>176177 <div class="peach-settings-body">178179 {}180 <div class="peach-settings-group peach-export-card peach-export-card--primary">181 <p class="peach-settings-label">backup phrase</p>182 <p class="peach-export-card-copy">183 Your vault as a string of characters you can copy anywhere — no file needed. Paste it into Peach on any device to restore.184 </p>185 <button186 class="peach-btn peach-btn--subtle peach-btn--sm"187 onClick={props.onOpenCjk}188 >189 Create backup phrase190 </button>191 </div>192193 {}194 <div class="peach-settings-group peach-export-card">195 <p class="peach-settings-label">encrypted file</p>196 <p class="peach-export-card-copy">197 Download a file backup. Set a password to lock it — you'll need that password to restore.198 </p>199 <div class="peach-settings-stack peach-export-password-stack">200 <input201 class="peach-settings-input"202 type="password"203 placeholder={`backup password (min ${MIN_EXPORT_PASSWORD} chars)`}204 value={exportPassword()}205 onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}206 />207 <input208 id="export-confirm-password"209 class="peach-settings-input"210 type="password"211 placeholder="confirm backup password"212 value={exportPasswordConfirm()}213 onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}214 />215 <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>216 <p class="peach-export-note" style="color:var(--peach-t2)">217 Passwords don't match218 </p>219 </Show>220 <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>221 <p class="peach-export-note" style="color:var(--peach-t2)">222 At least {MIN_EXPORT_PASSWORD} characters223 </p>224 </Show>225 </div>226 <button227 class="peach-btn peach-btn--subtle peach-btn--sm"228 onClick={() => void handleEncryptedExport()}229 disabled={exporting() || !passwordsMatch()}230 >231 {exporting() ? "saving…" : "Download backup file"}232 </button>233 </div>234235 {}236 <div class="peach-settings-group peach-export-card">237 <p class="peach-settings-label">CSV for other apps</p>238 <p class="peach-export-card-copy">239 Plain-text export for importing into another password manager. Passwords will be readable — delete the file once you're done.240 </p>241 <Show242 when={!showCsvWarning()}243 fallback={244 <div class="peach-settings-actions">245 <button246 class="peach-btn peach-btn--ghost peach-btn--sm"247 onClick={() => void handleCSVExport()}248 disabled={exporting()}249 >250 {exporting() ? "saving…" : "Download CSV"}251 </button>252 <button253 class="peach-btn peach-btn--ghost peach-btn--sm"254 onClick={() => setShowCsvWarning(false)}255 >256 cancel257 </button>258 </div>259 }260 >261 <button262 class="peach-btn peach-btn--ghost peach-btn--sm"263 onClick={() => setShowCsvWarning(true)}264 >265 Export as CSV266 </button>267 </Show>268 </div>269270 <Show when={error()}>271 <div class="peach-settings-group">272 <div class="peach-restore-error">{error()}</div>273 </div>274 </Show>275276 <Show when={result()}>277 <div class="peach-settings-group">278 <div class="peach-export-success">279 <span class="peach-export-success-check">✓</span>280 <span>{result()}</span>281 </div>282 </div>283 </Show>284 </div>285 </div>286 );287}288289function dateStamp(): string {290 return new Date().toISOString().split("T")[0];291}292293function downloadBlob(content: string, mimeType: string, filename: string): void {294 try {295 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));296 const a = document.createElement("a");297 a.href = url;298 a.download = filename;299 document.body.appendChild(a);300 a.click();301 document.body.removeChild(a);302 URL.revokeObjectURL(url);303 } catch (err) {304 console.error("[Peach] Download failed:", err);305 throw err;306 }307}