// Signal-conditioned views in this file must use , never a // signal-conditioned early return (`if (signal()) return `). Early // returns in SolidJS run once at mount and never re-execute when signals // change — see Projects.md §4.1. import { createMemo, createSignal, Show } from "solid-js"; import { sendMessage } from "../App"; import { PeachWordmark } from "../components/PeachMark"; import { parseImportSource, type ImportParseResult, } from "../../shared/import"; import type { VaultEntry } from "../../shared/types"; import { CJKImportView } from "./CJKImport"; interface ImportProps { mode?: "existing" | "onboarding"; replacesExistingVault?: boolean; onBack: () => void; onImport: () => void | Promise; } type ImportMergeMode = "add" | "replace"; export function ImportView(props: ImportProps) { const [showCjkImport, setShowCjkImport] = createSignal(false); return ( setShowCjkImport(true)} /> } > setShowCjkImport(false)} onDone={async () => { setShowCjkImport(false); await props.onImport(); }} /> ); } function MainImportView(props: ImportProps & { onOpenCjk: () => void }) { const mode = () => props.mode ?? "existing"; const [preview, setPreview] = createSignal(null); const [rawImport, setRawImport] = createSignal(null); const [backupPassword, setBackupPassword] = createSignal(""); const [masterPassword, setMasterPassword] = createSignal(""); const [masterConfirm, setMasterConfirm] = createSignal(""); const [importing, setImporting] = createSignal(false); const [error, setError] = createSignal(null); const [mergeMode, setMergeMode] = createSignal("add"); const [awaitingReplaceConfirm, setAwaitingReplaceConfirm] = createSignal(false); const [importProgress, setImportProgress] = createSignal<{ current: number; total: number } | null>(null); const [duplicateWarning, setDuplicateWarning] = createSignal(0); const [existingCount, setExistingCount] = createSignal(0); const needsBackupPassword = createMemo(() => preview()?.requiresPassword ?? false); const needsMasterPassword = createMemo(() => mode() === "onboarding"); const masterPasswordReady = createMemo( () => masterPassword().length >= 8 && masterPassword() === masterConfirm() ); async function refreshExistingCount() { if (mode() !== "existing") return; const res = await sendMessage({ type: "GET_ENTRIES" }); const list = (res as { ok: boolean; data?: VaultEntry[] }).data ?? []; setExistingCount(list.length); } void refreshExistingCount(); async function parseSource(text: string, password?: string) { try { const result = await parseImportSource(text, password); setPreview(result); setError(null); if (result.format === "unknown") { setError( "We couldn't recognise this file format. Peach supports: Peach JSON backups, Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports." ); } return result; } catch (err) { const message = err instanceof Error ? err.message : String(err); setPreview(null); setError(getImportErrorCopy(message)); return null; } } async function handleFileSelect(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; setPreview(null); setRawImport(null); setBackupPassword(""); setError(null); setAwaitingReplaceConfirm(false); const text = await file.text(); setRawImport(text); await parseSource(text); } async function handleUnlockBackup() { const raw = rawImport(); if (!raw || !backupPassword().trim()) return; await parseSource(raw, backupPassword()); } async function countDuplicates(incoming: ImportParseResult["entries"]): Promise { if (incoming.length === 0) return 0; const res = await sendMessage({ type: "GET_ENTRIES" }); const existing = ((res as { ok: boolean; data?: VaultEntry[] }).data ?? []).filter((e) => !e.deletedAt); const keyOf = (title?: string, url?: string, username?: string) => `${(title ?? "").toLowerCase()}|${(url ?? "").toLowerCase()}|${(username ?? "").toLowerCase()}`; const existingKeys = new Set(existing.map((e) => keyOf(e.title, e.url, e.username))); let dupes = 0; for (const entry of incoming) { const u = "username" in entry ? entry.username : undefined; const url = "url" in entry ? entry.url : undefined; if (existingKeys.has(keyOf(entry.title, url, u))) dupes += 1; } return dupes; } async function handleImport() { const currentPreview = preview(); if (!currentPreview) return; if (needsBackupPassword() && !backupPassword().trim()) { setError("Enter the backup password used for this export."); return; } if (needsMasterPassword()) { if (masterPassword().length < 8) { setError("Master password must be at least 8 characters."); return; } if (!masterPasswordReady()) { setError("Master passwords do not match."); return; } } if (mode() === "existing" && mergeMode() === "replace" && !awaitingReplaceConfirm()) { setAwaitingReplaceConfirm(true); return; } setImporting(true); setError(null); setDuplicateWarning(0); setImportProgress(null); try { // ⚠️ Guard: verify entries exist BEFORE touching any vault. // Without this, CREATE_VAULT would already have wiped the old vault // before we discover the file has nothing importable. if (currentPreview.entries.length === 0) { throw new Error("NO_ENTRIES_IMPORTED"); } if (needsMasterPassword()) { const createResponse = await sendMessage({ type: "CREATE_VAULT", payload: { password: masterPassword() }, }); if (!(createResponse as { ok: boolean }).ok) { throw new Error("CREATE_VAULT_FAILED"); } } if (mode() === "existing" && mergeMode() === "replace") { const clearRes = await sendMessage({ type: "CLEAR_VAULT_ENTRIES" }); if (!(clearRes as { ok: boolean }).ok) { throw new Error("CLEAR_VAULT_FAILED"); } } let dupes = 0; if (mode() === "existing" && mergeMode() === "add") { dupes = await countDuplicates(currentPreview.entries); } let imported = 0; setImportProgress({ current: 0, total: currentPreview.entries.length }); const batchRes = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: currentPreview.entries }, }); if ((batchRes as { ok: boolean }).ok) { imported = ((batchRes as { ok: boolean; data?: VaultEntry[] }).data ?? []).length; setImportProgress({ current: imported, total: currentPreview.entries.length }); } else { // Fallback to sequential for (const entry of currentPreview.entries) { const response = await sendMessage({ type: "ADD_ENTRY", payload: entry, }); if ((response as { ok: boolean }).ok) imported += 1; setImportProgress({ current: imported, total: currentPreview.entries.length }); } } if (imported === 0) { throw new Error("NO_ENTRIES_IMPORTED"); } // Apply license last — silent on failure, never blocks a successful import. if (currentPreview.licenseKey) { await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: currentPreview.licenseKey } }).catch(() => {}); } setDuplicateWarning(dupes); await props.onImport(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(getImportErrorCopy(message)); setImporting(false); setImportProgress(null); return; } setImporting(false); setImportProgress(null); } return (
import
} >

{mode() === "onboarding" ? "bring data into peach" : "import passwords"}

{mode() === "onboarding" ? "Import a Peach backup, FIDO credential-exchange export, Proton Pass export, Bitwarden export, or a common browser CSV. We'll create a fresh local vault first." : "Supports Peach encrypted backups, FIDO credential-exchange passkeys, Proton Pass, Bitwarden, and common browser CSV exports."}

Restoring now replaces the locked local vault on this device with a fresh one from your backup or export file.
Peach JSON FIDO passkeys Proton Pass CSV/JSON Bitwarden CSV/JSON 1Password JSON Chrome / Edge / Safari CSV

restore from CJK phrase

Use a Peach CJK backup phrase instead of a file.

select file

void handleFileSelect(event)} />

unlock backup

Enter the password used when you created this encrypted Peach JSON backup.

setBackupPassword((event.target as HTMLInputElement).value) } onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void handleUnlockBackup(); } }} />

{preview()!.label}

Found {preview()!.entries.length}{" "} entr{preview()!.entries.length === 1 ? "y" : "ies"} {preview()!.skippedItems > 0 ? `. ${preview()!.skippedItems} skipped because no password was present.` : "."}
{preview()!.entries.slice(0, 8).map((entry) => (
{entry.title} {entry.type === "passkey" ? `${entry.userName || "passkey"} • ${entry.rpId || "site"}` : entry.type === "note" ? "secure note" : entry.username || entry.url || "password item"}
))} 8}>
...and {preview()!.entries.length - 8} more
0}>

merge or replace

Replace forever? This cannot be undone.

new master password

This password unlocks the Peach vault created on this device after import. It is separate from any export password.

setMasterPassword((event.target as HTMLInputElement).value) } /> setMasterConfirm((event.target as HTMLInputElement).value) } />
{error()}
Importing... [{importProgress()!.current} / {importProgress()!.total}]
0 && !importing()}>
{duplicateWarning()} {duplicateWarning() === 1 ? "entry appears" : "entries appear"} to be duplicates of existing ones. They were still imported — you can review and delete them in your vault.
); } function getImportErrorCopy(error: string): string { switch (error) { case "UNSUPPORTED_IMPORT_FORMAT": return "Unrecognised file format. Use CSV, Bitwarden JSON, Proton Pass JSON/CSV, or a Peach encrypted backup."; case "INVALID_EXPORT_PASSWORD": return "Incorrect backup password. This is the password you chose when you exported the Peach JSON backup."; case "INVALID_PEACH_EXPORT": return "This Peach backup file looks corrupted or incomplete."; case "CREATE_VAULT_FAILED": return "Peach couldn't create a new local vault before importing."; case "CLEAR_VAULT_FAILED": return "Peach couldn't clear the existing vault before replacing it."; case "NO_ENTRIES_IMPORTED": return "No importable password entries were found in that file."; default: return error; } }