// 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");
}
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 (
}
>
{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.
Restore from CJK phrase
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();
}
}}
/>
void handleUnlockBackup()}
disabled={!backupPassword().trim()}
>
Decrypt →
{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}>
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.
void handleImport()}
disabled={
importing() ||
preview()!.entries.length === 0 ||
(needsMasterPassword() && !masterPasswordReady())
}
>
{importing()
? "importing..."
: mode() === "onboarding"
? `Create vault + import ${preview()!.entries.length}`
: mergeMode() === "replace"
? awaitingReplaceConfirm()
? "Yes, replace forever"
: `Replace vault with ${preview()!.entries.length}`
: `Import ${preview()!.entries.length}`}
Cancel
);
}
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;
}
}