From d969c7b37513575a365872c17510597b2ca594be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 19:55:05 +0000 Subject: [PATCH] feat: bulk import from CSV, password managers, browser autofill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module: import-parser.js - Auto-detects format from filename and content headers - Chrome/Firefox password CSV: extracts usernames, emails, domains (NEVER imports passwords) - Bitwarden/1Password CSV: extracts usernames, emails, domains - Browser autofill CSV: extracts names, emails, phones, addresses - Plain CSV: two-column real→substitute with optional category - Plain text: one value per line, auto-categorizes (email, phone, name) - Values without substitutes are marked "needs mapping" so the user can see what needs filling in Options UI: - Import button in Transfer Data section - Preview panel shows parsed items before applying - Summary shows counts and highlights items needing substitutes - Apply merges into active profile (identity) and mappings table https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn --- src/lib/import-parser.js | 410 +++++++++++++++++++++++++++++++++++++++ src/options/options.html | 26 +++ src/options/options.js | 145 ++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 src/lib/import-parser.js diff --git a/src/lib/import-parser.js b/src/lib/import-parser.js new file mode 100644 index 0000000..55dc238 --- /dev/null +++ b/src/lib/import-parser.js @@ -0,0 +1,410 @@ +/** + * Silent Send - Import Parser + * + * Parses bulk import files to pre-populate identity and mappings. + * + * Supported formats: + * + * 1. CSV/TSV mappings — two columns: real, substitute + * Optional third column: category + * Header row auto-detected and skipped. + * + * 2. Real-values-only list — one value per line + * Imports as identity fields with blank substitutes so the user + * can see what needs mapping and fill in fakes. + * + * 3. Chrome password CSV export — extracts usernames, names, URLs + * Columns: name, url, username, password, note + * Passwords are NEVER imported. Only usernames/emails/URLs. + * + * 4. Firefox password CSV export — similar to Chrome + * Columns: url, username, password, ... + * + * 5. Bitwarden CSV export — extracts identity fields + * Columns: folder, favorite, type, name, login_uri, login_username, ... + * + * 6. 1Password CSV export — extracts identity fields + * Various formats, but typically: Title, URL, Username, Password, ... + * + * 7. Browser autofill CSV — Chrome's autofill export + * Columns vary but typically include: name, email, phone, address + */ + +const ImportParser = { + /** + * Auto-detect format and parse. + * Returns { mappings: [], identity: { names, emails, usernames, phones, addresses } } + */ + parse(text, filename = '') { + const lower = filename.toLowerCase(); + + // Try to detect format from filename + if (lower.includes('password') || lower.includes('logins')) { + return this.parsePasswordCSV(text); + } + if (lower.includes('bitwarden')) { + return this.parseBitwardenCSV(text); + } + if (lower.includes('1password')) { + return this.parse1PasswordCSV(text); + } + if (lower.includes('autofill') || lower.includes('address')) { + return this.parseAutofillCSV(text); + } + + // Auto-detect from content + const lines = text.trim().split('\n'); + if (lines.length === 0) return this._empty(); + + const firstLine = lines[0].toLowerCase(); + + // CSV with headers + if (firstLine.includes('username') || firstLine.includes('password') || firstLine.includes('login')) { + return this.parsePasswordCSV(text); + } + if (firstLine.includes('bitwarden') || firstLine.includes('folder,favorite')) { + return this.parseBitwardenCSV(text); + } + + // Check if it's a two-column CSV (real → substitute mapping) + const hasTwoColumns = lines.some(l => l.includes(',') || l.includes('\t')); + if (hasTwoColumns) { + return this.parseMappingCSV(text); + } + + // Plain list — one value per line (real values only) + return this.parseValueList(text); + }, + + /** + * Parse a two-column CSV: real,substitute[,category] + */ + parseMappingCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + const sep = lines[0].includes('\t') ? '\t' : ','; + + for (let i = 0; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], sep); + if (cols.length < 2) continue; + + const real = cols[0].trim(); + const substitute = cols[1].trim(); + + // Skip header row + if (i === 0 && this._isHeader(real, substitute)) continue; + if (!real) continue; + + const category = (cols[2] || '').trim().toLowerCase() || this._guessCategory(real); + + result.mappings.push({ + real, + substitute: substitute || '', // may be blank — needs mapping + category, + needsMapping: !substitute, + }); + } + + return result; + }, + + /** + * Parse a plain list of real values (one per line). + * All imported as needing substitutes. + */ + parseValueList(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + + for (const line of lines) { + const value = line.trim(); + if (!value || value.length < 2) continue; + + const category = this._guessCategory(value); + + // Route to identity or mappings based on detected category + if (category === 'email') { + result.identity.emails.push({ real: value, substitute: '' }); + } else if (category === 'phone') { + result.identity.phones.push({ real: value, substitute: '' }); + } else if (category === 'name') { + result.identity.names.push({ real: value, substitute: '', type: 'first' }); + } else { + result.mappings.push({ real: value, substitute: '', category, needsMapping: true }); + } + } + + return result; + }, + + /** + * Parse Chrome/Firefox password CSV export. + * NEVER imports passwords — only usernames, emails, and domains. + */ + parsePasswordCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + const usernameIdx = headers.findIndex(h => h === 'username' || h === 'login_username' || h === 'user'); + const urlIdx = headers.findIndex(h => h === 'url' || h === 'login_uri' || h === 'origin' || h === 'web site'); + const nameIdx = headers.findIndex(h => h === 'name' || h === 'title'); + + const seenEmails = new Set(); + const seenUsernames = new Set(); + const seenDomains = new Set(); + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + // Extract username/email + if (usernameIdx >= 0 && cols[usernameIdx]) { + const username = cols[usernameIdx].trim(); + if (username && !seenEmails.has(username) && !seenUsernames.has(username)) { + if (username.includes('@')) { + seenEmails.add(username); + result.identity.emails.push({ real: username, substitute: '' }); + } else if (username.length >= 3) { + seenUsernames.add(username); + result.identity.usernames.push({ real: username, substitute: '' }); + } + } + } + + // Extract domain from URL + if (urlIdx >= 0 && cols[urlIdx]) { + try { + const domain = new URL(cols[urlIdx].trim()).hostname; + if (domain && !seenDomains.has(domain) && !this._isCommonDomain(domain)) { + seenDomains.add(domain); + result.mappings.push({ + real: domain, + substitute: '', + category: 'domain', + needsMapping: true, + }); + } + } catch { /* invalid URL */ } + } + } + + return result; + }, + + /** + * Parse Bitwarden CSV export. + */ + parseBitwardenCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + const usernameIdx = headers.findIndex(h => h.includes('username')); + const uriIdx = headers.findIndex(h => h.includes('uri') || h.includes('url')); + + const seen = new Set(); + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + if (usernameIdx >= 0 && cols[usernameIdx]) { + const val = cols[usernameIdx].trim(); + if (val && !seen.has(val)) { + seen.add(val); + if (val.includes('@')) { + result.identity.emails.push({ real: val, substitute: '' }); + } else if (val.length >= 3) { + result.identity.usernames.push({ real: val, substitute: '' }); + } + } + } + + if (uriIdx >= 0 && cols[uriIdx]) { + try { + const domain = new URL(cols[uriIdx].trim()).hostname; + if (domain && !seen.has(domain) && !this._isCommonDomain(domain)) { + seen.add(domain); + result.mappings.push({ real: domain, substitute: '', category: 'domain', needsMapping: true }); + } + } catch { /* skip */ } + } + } + + return result; + }, + + /** + * Parse 1Password CSV export. + */ + parse1PasswordCSV(text) { + // 1Password CSV is similar enough to handle like password CSV + return this.parsePasswordCSV(text); + }, + + /** + * Parse browser autofill/address CSV. + * Extracts names, emails, phones, addresses. + */ + parseAutofillCSV(text) { + const result = this._empty(); + const lines = text.trim().split('\n'); + if (lines.length < 2) return result; + + const headers = this._splitCSVLine(lines[0], ',').map(h => h.trim().toLowerCase()); + + const nameFields = ['name', 'full name', 'first name', 'last name', 'given name', 'family name']; + const emailFields = ['email', 'e-mail', 'email address']; + const phoneFields = ['phone', 'phone number', 'tel', 'telephone']; + const addressFields = ['address', 'street', 'address line 1', 'street address']; + + const findIdx = (targets) => headers.findIndex(h => targets.some(t => h.includes(t))); + + const nameIdx = findIdx(nameFields); + const firstNameIdx = headers.findIndex(h => h === 'first name' || h === 'given name'); + const lastNameIdx = headers.findIndex(h => h === 'last name' || h === 'family name'); + const emailIdx = findIdx(emailFields); + const phoneIdx = findIdx(phoneFields); + const addressIdx = findIdx(addressFields); + + const seen = new Set(); + + for (let i = 1; i < lines.length; i++) { + const cols = this._splitCSVLine(lines[i], ','); + + // Names + if (firstNameIdx >= 0 && cols[firstNameIdx]) { + const val = cols[firstNameIdx].trim(); + if (val && !seen.has('fn:' + val)) { + seen.add('fn:' + val); + result.identity.names.push({ real: val, substitute: '', type: 'first' }); + } + } + if (lastNameIdx >= 0 && cols[lastNameIdx]) { + const val = cols[lastNameIdx].trim(); + if (val && !seen.has('ln:' + val)) { + seen.add('ln:' + val); + result.identity.names.push({ real: val, substitute: '', type: 'last' }); + } + } + if (nameIdx >= 0 && cols[nameIdx] && firstNameIdx < 0) { + const val = cols[nameIdx].trim(); + if (val && !seen.has('n:' + val)) { + seen.add('n:' + val); + // Split "First Last" into two entries + const parts = val.split(/\s+/); + if (parts.length >= 2) { + result.identity.names.push({ real: parts[0], substitute: '', type: 'first' }); + result.identity.names.push({ real: parts.slice(1).join(' '), substitute: '', type: 'last' }); + } else { + result.identity.names.push({ real: val, substitute: '', type: 'first' }); + } + } + } + + // Emails + if (emailIdx >= 0 && cols[emailIdx]) { + const val = cols[emailIdx].trim(); + if (val && !seen.has('e:' + val)) { + seen.add('e:' + val); + result.identity.emails.push({ real: val, substitute: '' }); + } + } + + // Phones + if (phoneIdx >= 0 && cols[phoneIdx]) { + const val = cols[phoneIdx].trim(); + if (val && !seen.has('p:' + val)) { + seen.add('p:' + val); + result.identity.phones.push({ real: val, substitute: '' }); + } + } + + // Addresses + if (addressIdx >= 0 && cols[addressIdx]) { + const val = cols[addressIdx].trim(); + if (val && !seen.has('a:' + val)) { + seen.add('a:' + val); + result.mappings.push({ real: val, substitute: '', category: 'address', needsMapping: true }); + } + } + } + + return result; + }, + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + _empty() { + return { + mappings: [], + identity: { + names: [], + emails: [], + usernames: [], + hostnames: [], + phones: [], + }, + }; + }, + + _isHeader(a, b) { + const headers = ['real', 'substitute', 'fake', 'original', 'replacement', 'from', 'to', 'value', 'category', 'type']; + return headers.includes(a.toLowerCase()) || headers.includes(b.toLowerCase()); + }, + + _guessCategory(value) { + if (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return 'email'; + if (/^[\d\s()+.-]{7,}$/.test(value)) return 'phone'; + if (/^\d{3}-\d{2}-\d{4}$/.test(value)) return 'ssn'; + if (/\d{1,5}\s+\w+\s+(st|street|ave|avenue|blvd|dr|drive|rd|road|ln|lane)/i.test(value)) return 'address'; + if (/^[a-z][a-z0-9._-]*$/i.test(value) && value.length >= 3 && value.length <= 20) return 'general'; + if (/^[A-Z][a-z]+(\s[A-Z][a-z]+)*$/.test(value)) return 'name'; + return 'general'; + }, + + /** + * Split a CSV line respecting quoted fields. + */ + _splitCSVLine(line, sep = ',') { + const result = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch === sep && !inQuotes) { + result.push(current); + current = ''; + } else { + current += ch; + } + } + result.push(current); + + // Strip surrounding quotes + return result.map(s => s.replace(/^"|"$/g, '')); + }, + + _isCommonDomain(domain) { + const common = new Set([ + 'google.com', 'facebook.com', 'twitter.com', 'x.com', 'amazon.com', + 'apple.com', 'microsoft.com', 'github.com', 'youtube.com', 'reddit.com', + 'netflix.com', 'linkedin.com', 'instagram.com', 'wikipedia.org', + 'stackoverflow.com', 'accounts.google.com', 'login.microsoftonline.com', + ]); + return common.has(domain); + }, +}; + +export default ImportParser; diff --git a/src/options/options.html b/src/options/options.html index e0bb909..f71b13f 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -398,6 +398,32 @@ + +
+

Bulk Import — populate from existing data

+

+ Import real values from a CSV, password manager export, or browser autofill export. + Passwords are never imported — only usernames, emails, names, and domains. + Imported values appear with blank substitutes so you can fill in fakes. +

+
+ + + Chrome passwords, Firefox logins, Bitwarden, 1Password, autofill, or plain CSV +
+ + + +
+
diff --git a/src/options/options.js b/src/options/options.js index 0453d6a..4c1d3a7 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -2,6 +2,7 @@ import Storage from '../lib/storage.js'; import SilentSendCrypto from '../lib/crypto.js'; import SilentSendSync from '../lib/sync.js'; import VersionHistory from '../lib/version-history.js'; +import ImportParser from '../lib/import-parser.js'; import SilentSendMerge from '../lib/merge.js'; import OrgPolicy from '../lib/org-policy.js'; import TamperGuard from '../lib/tamper-guard.js'; @@ -237,6 +238,14 @@ document.addEventListener('DOMContentLoaded', async () => { $('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click()); $('#fileImportAll').addEventListener('change', importAll); + // Bulk import + $('#btnBulkImport').addEventListener('click', () => $('#fileBulkImport').click()); + $('#fileBulkImport').addEventListener('change', handleBulkImport); + $('#btnCancelBulkImport').addEventListener('click', () => { + $('#bulkImportPreview').style.display = 'none'; + $('#fileBulkImport').value = ''; + }); + // Custom domains $('#btnAddDomain').addEventListener('click', addDomain); $('#newDomain').addEventListener('keydown', (e) => { @@ -1497,6 +1506,142 @@ function renderConflicts(conflicts) { }); } +// ---------------------------------------------------------------- +// Bulk Import +// ---------------------------------------------------------------- + +let pendingImport = null; + +async function handleBulkImport(e) { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const result = ImportParser.parse(text, file.name); + pendingImport = result; + + // Build summary + const parts = []; + if (result.identity.names.length) parts.push(`${result.identity.names.length} name(s)`); + if (result.identity.emails.length) parts.push(`${result.identity.emails.length} email(s)`); + if (result.identity.usernames.length) parts.push(`${result.identity.usernames.length} username(s)`); + if (result.identity.phones.length) parts.push(`${result.identity.phones.length} phone(s)`); + if (result.mappings.length) parts.push(`${result.mappings.length} mapping(s)`); + + const needsMapping = result.mappings.filter(m => m.needsMapping).length + + result.identity.names.filter(n => !n.substitute).length + + result.identity.emails.filter(e => !e.substitute).length + + result.identity.usernames.filter(u => !u.substitute).length + + result.identity.phones.filter(p => !p.substitute).length; + + $('#bulkImportSummary').innerHTML = ` + Found: ${parts.join(', ')}. + ${needsMapping > 0 ? `${needsMapping} item(s) need substitutes — you can add them after import.` : ''} + `; + + // Build preview list + const items = []; + for (const n of result.identity.names) { + items.push(`
name: ${escapeHtml(n.real)}${n.substitute ? ' → ' + escapeHtml(n.substitute) : ' needs substitute'}
`); + } + for (const e of result.identity.emails) { + items.push(`
email: ${escapeHtml(e.real)}${e.substitute ? ' → ' + escapeHtml(e.substitute) : ' needs substitute'}
`); + } + for (const u of result.identity.usernames) { + items.push(`
username: ${escapeHtml(u.real)}${u.substitute ? ' → ' + escapeHtml(u.substitute) : ' needs substitute'}
`); + } + for (const p of result.identity.phones) { + items.push(`
phone: ${escapeHtml(p.real)}${p.substitute ? ' → ' + escapeHtml(p.substitute) : ' needs substitute'}
`); + } + for (const m of result.mappings) { + items.push(`
${escapeHtml(m.category)}: ${escapeHtml(m.real)}${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' needs substitute'}
`); + } + + $('#bulkImportItems').innerHTML = items.slice(0, 50).join('') + + (items.length > 50 ? `
+${items.length - 50} more...
` : ''); + + $('#bulkImportPreview').style.display = 'block'; + + // Wire up apply button + $('#btnApplyBulkImport').onclick = applyBulkImport; + + setBulkImportStatus(`Parsed ${file.name} — review and click Apply.`, 'ok'); + } catch (err) { + setBulkImportStatus('Failed to parse: ' + err.message, 'error'); + } + + e.target.value = ''; +} + +async function applyBulkImport() { + if (!pendingImport) return; + + const result = pendingImport; + let addedCount = 0; + + // Add to identity (first active profile) + const profiles = await Storage.getProfiles(); + if (profiles.length > 0) { + const profile = profiles.find(p => p.active) || profiles[0]; + + if (result.identity.names.length) { + profile.names = [...(profile.names || []), ...result.identity.names]; + addedCount += result.identity.names.length; + } + if (result.identity.emails.length) { + profile.emails = [...(profile.emails || []), ...result.identity.emails]; + addedCount += result.identity.emails.length; + } + if (result.identity.usernames.length) { + profile.usernames = [...(profile.usernames || []), ...result.identity.usernames]; + addedCount += result.identity.usernames.length; + } + if (result.identity.phones.length) { + profile.phones = [...(profile.phones || []), ...result.identity.phones]; + addedCount += result.identity.phones.length; + } + + await Storage.updateProfile(profile.id, profile); + } + + // Add mappings + for (const m of result.mappings) { + await Storage.addMapping({ + real: m.real, + substitute: m.substitute || '', + category: m.category || 'general', + caseSensitive: false, + }); + addedCount++; + } + + // Refresh UI + mappings = await Storage.getMappings(); + renderMappings(); + + $('#bulkImportPreview').style.display = 'none'; + pendingImport = null; + + const needsSubs = result.mappings.filter(m => !m.substitute).length + + result.identity.names.filter(n => !n.substitute).length + + result.identity.emails.filter(e => !e.substitute).length + + result.identity.usernames.filter(u => !u.substitute).length + + result.identity.phones.filter(p => !p.substitute).length; + + setBulkImportStatus( + `Imported ${addedCount} items.${needsSubs > 0 ? ` ${needsSubs} still need substitutes — check Identity tab and Mappings.` : ''}`, + 'ok' + ); +} + +function setBulkImportStatus(msg, type) { + const el = $('#bulkImportStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + // ---------------------------------------------------------------- // Utility // ----------------------------------------------------------------