From 62f0aedc2724b5e6a68f3bc99006e94ed63cfe54 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 25 Apr 2026 23:45:22 +0800 Subject: [PATCH] feat: enhance OnePassword CSV parsing with improved field handling and new category type support --- webapp/src/lib/import-format-shared.ts | 7 +- webapp/src/lib/import-formats-browser.ts | 58 +++++---- webapp/src/lib/import-formats-onepassword.ts | 124 +++++++++++++++---- 3 files changed, 141 insertions(+), 48 deletions(-) diff --git a/webapp/src/lib/import-format-shared.ts b/webapp/src/lib/import-format-shared.ts index 67363c3..70a7f0f 100644 --- a/webapp/src/lib/import-format-shared.ts +++ b/webapp/src/lib/import-format-shared.ts @@ -205,7 +205,7 @@ export function parseCsv(raw: string): CsvRow[] { rows.push(row); const nonEmpty = rows.filter((r) => r.some((c) => txt(c))); if (!nonEmpty.length) return []; - const headers = nonEmpty[0].map((h) => txt(h)); + const headers = nonEmpty[0].map((h, index) => txt(index === 0 ? h.replace(/^\uFEFF/, '') : h)); const out: CsvRow[] = []; for (let i = 1; i < nonEmpty.length; i++) { const values = nonEmpty[i]; @@ -263,9 +263,12 @@ export function processKvp(cipher: Record, key: string, value: const v = txt(value); if (!v) return; const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array>) : []; + if (fields.some((field) => txt(field.name) === k && txt(field.value) === v)) return; if (v.length > 200 || /\r\n|\r|\n/.test(v)) { const existing = txt(cipher.notes); - cipher.notes = `${existing}${existing ? '\n' : ''}${k ? `${k}: ` : ''}${v}`; + const entry = `${k ? `${k}: ` : ''}${v}`; + if (existing.split('\n').some((line) => line === entry)) return; + cipher.notes = `${existing}${existing ? '\n' : ''}${entry}`; return; } fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null }); diff --git a/webapp/src/lib/import-formats-browser.ts b/webapp/src/lib/import-formats-browser.ts index 5dead49..799d886 100644 --- a/webapp/src/lib/import-formats-browser.ts +++ b/webapp/src/lib/import-formats-browser.ts @@ -1,5 +1,5 @@ import type { CiphersImportPayload } from '@/lib/api/vault'; -import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, txt, val } from '@/lib/import-format-shared'; +import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared'; export function parseChromeCsv(textRaw: string): CiphersImportPayload { const rows = parseCsv(textRaw); @@ -62,25 +62,37 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload { export function parseBitwardenCsv(textRaw: string): CiphersImportPayload { const rows = parseCsv(textRaw); const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; + const applyBitwardenCustomFields = (cipher: Record, rawFields: unknown) => { + const lines = String(rawFields || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + const delim = line.lastIndexOf(': '); + if (delim < 0) continue; + processKvp(cipher, line.slice(0, delim), line.slice(delim + 2), false); + } + }; for (const row of rows) { const type = txt(row.type).toLowerCase() || 'login'; - if (type === 'note') { - const idx = - result.ciphers.push({ - type: 2, - name: val(row.name, '--'), - notes: val(row.notes), - favorite: txt(row.favorite) === '1', - reprompt: 0, - key: null, - login: null, - card: null, - identity: null, - secureNote: { type: 0 }, - fields: null, - passwordHistory: null, - sshKey: null, - }) - 1; + if (type === 'note' || type === 'secure note' || type === 'securenote') { + const cipher = { + type: 2, + name: val(row.name, '--'), + notes: val(row.notes), + favorite: txt(row.favorite) === '1', + reprompt: Number(row.reprompt ?? 0) || 0, + key: null, + login: null, + card: null, + identity: null, + secureNote: { type: 0 }, + fields: [], + passwordHistory: null, + sshKey: null, + }; + applyBitwardenCustomFields(cipher, row.fields); + const idx = result.ciphers.push(cipher) - 1; addFolder(result, row.folder, idx); continue; } @@ -88,11 +100,13 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload { cipher.name = val(row.name, '--'); cipher.notes = val(row.notes); cipher.favorite = txt(row.favorite) === '1'; + cipher.reprompt = Number(row.reprompt ?? 0) || 0; + applyBitwardenCustomFields(cipher, row.fields); const login = cipher.login as Record; - login.username = val(row.login_username); - login.password = val(row.login_password); - login.totp = val(row.login_totp); - const uris = parseSerializedUris(row.login_uri || ''); + login.username = val(row.login_username, val(row.username)); + login.password = val(row.login_password, val(row.password)); + login.totp = val(row.login_totp, val(row.totp)); + const uris = parseSerializedUris(row.login_uri || row.uri || ''); login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null; const idx = result.ciphers.push(cipher) - 1; addFolder(result, row.folder, idx); diff --git a/webapp/src/lib/import-formats-onepassword.ts b/webapp/src/lib/import-formats-onepassword.ts index 1141faa..b8a92ca 100644 --- a/webapp/src/lib/import-formats-onepassword.ts +++ b/webapp/src/lib/import-formats-onepassword.ts @@ -25,14 +25,57 @@ function onePasswordTypeHints(typeName: string): 1 | 2 | 3 | 4 { return 1; } -function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 { +function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 | 5 { const c = txt(categoryUuid); if (['002', '101'].includes(c)) return 3; if (['004', '103', '104', '105', '106', '107', '108'].includes(c)) return 4; - if (['003', '100', '113'].includes(c)) return 2; + if (['003', '100', '111', '113'].includes(c)) return 2; + if (c === '114') return 5; return 1; } +function onePasswordCsvFieldLabel(property: string): string { + return txt(property) + .toLowerCase() + .replace(/^.*?:\s*/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function isOnePasswordUriField(property: string): boolean { + const label = onePasswordCsvFieldLabel(property); + return ['url', 'urls', 'website', 'web site'].includes(label); +} + +function isOnePasswordUsernameField(property: string): boolean { + return ['username', 'user name', 'email', 'e-mail', 'login'].includes(onePasswordCsvFieldLabel(property)); +} + +function isOnePasswordPasswordField(property: string): boolean { + return ['password', 'passphrase'].includes(onePasswordCsvFieldLabel(property)); +} + +function readOnePasswordFieldValue(rawValue: unknown): { value: string; kind: string; raw: unknown } { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return { value: txt(rawValue), kind: '', raw: rawValue }; + } + + const obj = rawValue as Record; + const keys = Object.keys(obj); + const kind = keys.find((key) => obj[key] !== null && obj[key] !== undefined && txt(obj[key]) !== '') || keys[0] || ''; + const raw = kind ? obj[kind] : undefined; + if (kind === 'date') { + const iso = parseEpochMaybe(raw); + return { value: iso ? new Date(iso).toUTCString() : txt(raw), kind, raw }; + } + if (kind === 'monthYear') return { value: txt(raw), kind, raw }; + if (kind === 'email' && raw && typeof raw === 'object') { + return { value: txt((raw as Record).email_address), kind, raw }; + } + if (kind === 'address' || kind === 'sshKey') return { value: '', kind, raw }; + return { value: txt(raw), kind, raw }; +} + export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImportPayload { const rows = parseCsv(textRaw); const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; @@ -87,19 +130,19 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp if (Number(cipher.type) === 1) { const login = cipher.login as Record; - if (!txt(login.username) && lower === 'username') { + if (!txt(login.username) && isOnePasswordUsernameField(lower)) { login.username = rawVal; continue; } - if (!txt(login.password) && lower === 'password') { + if (!txt(login.password) && isOnePasswordPasswordField(lower)) { login.password = rawVal; continue; } - if (lower === 'url' || lower === 'website') { + if (isOnePasswordUriField(lower)) { addLoginUri(login, rawVal); continue; } - if (!txt(login.totp) && isTotpFieldName(lower)) { + if (!txt(login.totp) && isTotpFieldName(onePasswordCsvFieldLabel(lower))) { login.totp = rawVal; continue; } @@ -156,7 +199,7 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp } } - if (!ignored.has(lower) && !isTotpFieldName(lower) && !lower.startsWith('section:') && !lower.startsWith('section ')) { + if (!ignored.has(lower) && !isTotpFieldName(onePasswordCsvFieldLabel(lower)) && !lower.startsWith('section:') && !lower.startsWith('section ')) { if (!altUsername && lower === 'email') altUsername = rawVal; if (lower === 'created date' || lower === 'modified date') { const readable = parseEpochMaybe(rawVal); @@ -388,6 +431,15 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload ssn: null, licenseNumber: null, }; + } else if (categoryType === 5) { + cipher.type = 5; + cipher.login = null; + cipher.sshKey = { + privateKey: null, + publicKey: null, + keyFingerprint: null, + fingerprint: null, + }; } cipher.favorite = Number(item?.favIndex) === 1; cipher.name = val(item?.overview?.title, '--'); @@ -399,9 +451,14 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload const uri = normalizeUri(u?.url || ''); if (uri) urls.push(uri); } - const fallbackUrl = normalizeUri(item?.overview?.url || ''); - if (fallbackUrl) urls.push(fallbackUrl); + if (!urls.length) { + const fallbackUrl = normalizeUri(item?.overview?.url || ''); + if (fallbackUrl) urls.push(fallbackUrl); + } setLoginUris(cipher.login as Record, urls); + if (txt(item?.categoryUuid) === '005' && !txt((cipher.login as Record).password)) { + (cipher.login as Record).password = val(item?.details?.password); + } } for (const loginField of item?.details?.loginFields || []) { @@ -409,14 +466,15 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload if (!lv) continue; const designation = txt(loginField?.designation).toLowerCase(); const fieldName = txt(loginField?.name); + const fieldLabel = onePasswordCsvFieldLabel(fieldName || designation); const fieldType = txt(loginField?.fieldType); if (Number(cipher.type) === 1) { const login = cipher.login as Record; - if (designation === 'username') { + if (designation === 'username' || isOnePasswordUsernameField(fieldLabel)) { login.username = lv; continue; } - if (designation === 'password') { + if (designation === 'password' || isOnePasswordPasswordField(fieldLabel)) { login.password = lv; continue; } @@ -432,14 +490,11 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload const fieldTitle = txt(section?.title); for (const field of section?.fields || []) { const fieldId = txt(field?.id); - const fieldType = txt(field?.value?.fieldType).toLowerCase(); + const parsedField = readOnePasswordFieldValue(field?.value); + const fieldType = parsedField.kind.toLowerCase(); const fieldTitleLocal = txt(field?.title) || fieldTitle; - const fieldValueObj = field?.value?.value; - let fieldValue = txt(fieldValueObj); - if (!fieldValue && typeof fieldValueObj === 'number') { - const iso = parseEpochMaybe(fieldValueObj); - fieldValue = iso ? new Date(iso).toUTCString() : String(fieldValueObj); - } + const fieldValueObj = parsedField.raw; + const fieldValue = parsedField.value; if (!fieldValue && !(fieldValueObj && typeof fieldValueObj === 'object')) continue; if (Number(cipher.type) === 3 && cipher.card) { @@ -516,34 +571,55 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload } } else if (Number(cipher.type) === 1) { const login = cipher.login as Record; - if (fieldId === 'url') { + if (fieldId === 'url' || fieldType === 'url') { addLoginUri(login, fieldValue); continue; } - if (fieldId === 'username' && !txt(login.username)) { + if ((fieldId === 'username' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'username') && !txt(login.username)) { login.username = fieldValue; continue; } - if (fieldId === 'password' && !txt(login.password)) { + if ((fieldId === 'password' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'password') && !txt(login.password)) { login.password = fieldValue; continue; } + if (txt(item?.categoryUuid) === '112' && onePasswordCsvFieldLabel(fieldTitleLocal) === 'credential' && !txt(login.password)) { + login.password = fieldValue; + continue; + } + if (txt(item?.categoryUuid) === '112' && onePasswordCsvFieldLabel(fieldTitleLocal) === 'hostname') { + addLoginUri(login, fieldValue); + continue; + } if ( - (fieldId === 'oneTimePassword' || fieldId === 'totp' || fieldType === 'otp' || isTotpFieldName(fieldTitleLocal)) && + (fieldId === 'oneTimePassword' || fieldId === 'totp' || fieldId.startsWith('TOTP_') || fieldType === 'totp' || fieldType === 'otp' || isTotpFieldName(fieldTitleLocal)) && !txt(login.totp) ) { login.totp = extractTotpValue(fieldValueObj) || fieldValue; continue; } + } else if (Number(cipher.type) === 5 && cipher.sshKey && fieldType === 'sshkey' && fieldValueObj && typeof fieldValueObj === 'object') { + const ssh = fieldValueObj as Record; + const metadata = ssh.metadata && typeof ssh.metadata === 'object' ? ssh.metadata : {}; + cipher.sshKey = { + privateKey: val(metadata.privateKey ?? ssh.privateKey), + publicKey: val(metadata.publicKey), + keyFingerprint: val(metadata.fingerprint), + fingerprint: val(metadata.fingerprint), + }; + continue; } - const hidden = fieldType === 'concealed' || fieldType === 'otp'; + const hidden = fieldType === 'concealed' || fieldType === 'totp' || fieldType === 'otp'; processKvp(cipher, fieldTitleLocal || fieldId || 'field', fieldValue, hidden); } } + parseOnePasswordPasswordHistory(cipher, item?.details?.passwordHistory || []); convertToNoteIfNeeded(cipher); const idx = result.ciphers.push(cipher) - 1; - if (vaultName) addFolder(result, vaultName, idx); + const tagFolder = Array.isArray(item?.overview?.tags) ? txt(item.overview.tags[0]) : ''; + if (tagFolder) addFolder(result, tagFolder, idx); + else if (vaultName) addFolder(result, vaultName, idx); } } }