feat: bulk import from CSV, password managers, browser autofill
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
This commit is contained in:
@@ -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;
|
||||||
@@ -398,6 +398,32 @@
|
|||||||
<button class="btn" id="btnImportAll">Import from File</button>
|
<button class="btn" id="btnImportAll">Import from File</button>
|
||||||
<input type="file" id="fileImportAll" accept=".json,.ssbackup" hidden>
|
<input type="file" id="fileImportAll" accept=".json,.ssbackup" hidden>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
|
||||||
|
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Bulk Import — populate from existing data</h3>
|
||||||
|
<p class="section-desc" style="margin-bottom:8px">
|
||||||
|
Import real values from a CSV, password manager export, or browser autofill export.
|
||||||
|
Passwords are <strong>never</strong> imported — only usernames, emails, names, and domains.
|
||||||
|
Imported values appear with blank substitutes so you can fill in fakes.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="btn btn-primary" id="btnBulkImport">Import CSV / Password Export</button>
|
||||||
|
<input type="file" id="fileBulkImport" accept=".csv,.tsv,.txt" hidden>
|
||||||
|
<span style="font-size:11px;color:#6b7280">Chrome passwords, Firefox logins, Bitwarden, 1Password, autofill, or plain CSV</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bulkImportPreview" style="display:none;margin-top:10px;padding:10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px">
|
||||||
|
<h4 style="font-size:12px;font-weight:600;margin:0 0 8px">Import Preview</h4>
|
||||||
|
<div id="bulkImportSummary" style="font-size:12px;margin-bottom:8px"></div>
|
||||||
|
<div id="bulkImportItems" style="max-height:200px;overflow-y:auto;font-size:11px;margin-bottom:8px"></div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-primary btn-sm" id="btnApplyBulkImport">Apply Import</button>
|
||||||
|
<button class="btn btn-sm" id="btnCancelBulkImport">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bulkImportStatus" style="font-size:12px;margin-top:6px;min-height:16px"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Storage from '../lib/storage.js';
|
|||||||
import SilentSendCrypto from '../lib/crypto.js';
|
import SilentSendCrypto from '../lib/crypto.js';
|
||||||
import SilentSendSync from '../lib/sync.js';
|
import SilentSendSync from '../lib/sync.js';
|
||||||
import VersionHistory from '../lib/version-history.js';
|
import VersionHistory from '../lib/version-history.js';
|
||||||
|
import ImportParser from '../lib/import-parser.js';
|
||||||
import SilentSendMerge from '../lib/merge.js';
|
import SilentSendMerge from '../lib/merge.js';
|
||||||
import OrgPolicy from '../lib/org-policy.js';
|
import OrgPolicy from '../lib/org-policy.js';
|
||||||
import TamperGuard from '../lib/tamper-guard.js';
|
import TamperGuard from '../lib/tamper-guard.js';
|
||||||
@@ -237,6 +238,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
|
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
|
||||||
$('#fileImportAll').addEventListener('change', importAll);
|
$('#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
|
// Custom domains
|
||||||
$('#btnAddDomain').addEventListener('click', addDomain);
|
$('#btnAddDomain').addEventListener('click', addDomain);
|
||||||
$('#newDomain').addEventListener('keydown', (e) => {
|
$('#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 ? `<span style="color:#b45309">${needsMapping} item(s) need substitutes — you can add them after import.</span>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Build preview list
|
||||||
|
const items = [];
|
||||||
|
for (const n of result.identity.names) {
|
||||||
|
items.push(`<div><span style="color:#6b7280">name:</span> <strong>${escapeHtml(n.real)}</strong>${n.substitute ? ' → ' + escapeHtml(n.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
|
}
|
||||||
|
for (const e of result.identity.emails) {
|
||||||
|
items.push(`<div><span style="color:#6b7280">email:</span> <strong>${escapeHtml(e.real)}</strong>${e.substitute ? ' → ' + escapeHtml(e.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
|
}
|
||||||
|
for (const u of result.identity.usernames) {
|
||||||
|
items.push(`<div><span style="color:#6b7280">username:</span> <strong>${escapeHtml(u.real)}</strong>${u.substitute ? ' → ' + escapeHtml(u.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
|
}
|
||||||
|
for (const p of result.identity.phones) {
|
||||||
|
items.push(`<div><span style="color:#6b7280">phone:</span> <strong>${escapeHtml(p.real)}</strong>${p.substitute ? ' → ' + escapeHtml(p.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
|
}
|
||||||
|
for (const m of result.mappings) {
|
||||||
|
items.push(`<div><span style="color:#6b7280">${escapeHtml(m.category)}:</span> <strong>${escapeHtml(m.real)}</strong>${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#bulkImportItems').innerHTML = items.slice(0, 50).join('') +
|
||||||
|
(items.length > 50 ? `<div style="color:#6b7280;margin-top:4px">+${items.length - 50} more...</div>` : '');
|
||||||
|
|
||||||
|
$('#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
|
// Utility
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user