feat(import): JSON / CSV vault import with heuristic column mapping

Round-trip companion to the existing doExport(). Supports two file
formats with auto-detection (extension + first-char sniff):

JSON
====
Native shape produced by doExport() AND a forgiving fallback for any
flat array of entry objects with site/url + password fields. Accepts:
  - { version, exported_at, entries: [...] }   (native)
  - [{ ... }, { ... }]                          (flat array)
  - mixed keys: site|url|name, username|user|login|email, etc.

CSV
===
RFC-4180-ish parser (~30 lines): quoted fields, escaped "", commas
inside quotes, CRLF line endings. No streaming since password-manager
imports are realistically MB-scale at most.

Heuristic column mapping (case + underscore tolerant) covers the
common exporters out of the box:

  Site/URL     : name, title, url, site, website, login_uri, login_url
  Username     : login_username, username, user, login, email
  Password     : login_password, password, pass, pwd
  Folder       : folder, group, category, path, collection
  Tags         : tags, labels (comma/semicolon-split)
  Notes        : notes, note, comment    (short notes joined into tags)
  TOTP         : login_totp, totp, otpauth, authenticator, two_factor

If the TOTP column holds a full otpauth:// URI it's parsed and only
the secret param is stored — same path used by the slide-over TOTP
field. Invalid base32 TOTP secrets are dropped silently rather than
failing the whole import.

Backend
=======
New endpoint: POST /entries/bulk-import
Body: { entries: [{ site, username, encrypted_password, iv, folder,
                    tags, totp_secret, totp_iv }, ... ] }
Caps at 10,000 entries per request as a sanity bound. Inserts inside
a single SQLite transaction — partial failure rolls back cleanly, the
user retries from the same source file. Returns { imported: N }.

Rows missing site or ciphertext are skipped within the transaction
(not failed) so one bad row in a 500-entry import doesn't blow up
the whole batch.

Client flow
===========
doImport():
  1. Hidden <input type="file" accept=".json,.csv"> picker
  2. Read text, detect format, route to parseEntriesFromJSON or CSV
  3. confirmDialog preview: count + first 3 sample sites + skipped rows
  4. On confirm: encryptImportEntry() each plaintext entry with the
     current vault key (reuses encryptPwd / base32Decode validation)
  5. Single POST to /entries/bulk-import
  6. Reload entries, refresh UI, trigger HIBP scan if enabled

UI
==
Two entry points (mirroring Export):
 - Sidebar "Import vault" nav item, next to "Export vault"
 - Settings panel "Import" section with descriptive blurb
Both call doImport(). New i-log-in icon added to the SVG sprite (mirror
of i-log-out used by Export).

Limitations
===========
 - No de-duplication: importing the same file twice yields duplicate
   entries. Trade-off to keep the v1 simple — the user can sort it
   out with the existing trash/multi-select UI.
 - No password-protected vault formats (Bitwarden encrypted JSON,
   KeePass kdbx). Only plaintext exports — same trade-off as
   doExport() which produces plaintext JSON.
This commit is contained in:
2026-05-23 05:30:08 +01:00
parent 60aa106a30
commit 4b15811221
3 changed files with 410 additions and 6 deletions
+17
View File
@@ -31,6 +31,7 @@
<symbol id="i-dice" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/><circle cx="16" cy="8" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="8" cy="16" r="1.2" fill="currentColor"/><circle cx="16" cy="16" r="1.2" fill="currentColor"/></symbol>
<symbol id="i-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82Z"/><line x1="7" y1="7" x2="7.01" y2="7"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></symbol>
<symbol id="i-log-in" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></symbol>
<symbol id="i-rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></symbol>
<symbol id="i-command" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3Z"/></symbol>
@@ -175,6 +176,10 @@
<svg><use href="#i-dice"/></svg>
<span>Generator</span>
</button>
<button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg>
<span>Import vault</span>
</button>
<button class="nav-item" id="sidebarExportBtn">
<svg><use href="#i-log-out"/></svg>
<span>Export vault</span>
@@ -346,6 +351,18 @@
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Import</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Import a vault from a JSON export (this app) or a CSV file
(Bitwarden, KeePass, Chrome, 1Password…). Entries are added
to your current vault — duplicates are NOT removed.
</p>
<button class="btn btn-ghost btn-sm" id="importBtn">
<svg><use href="#i-log-in"/></svg> Import vault
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Account</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px">