feat: enhance OnePassword CSV parsing with improved field handling and new category type support

This commit is contained in:
shuaiplus
2026-04-25 23:45:22 +08:00
parent 193e0ca189
commit 62f0aedc27
3 changed files with 141 additions and 48 deletions
+5 -2
View File
@@ -205,7 +205,7 @@ export function parseCsv(raw: string): CsvRow[] {
rows.push(row); rows.push(row);
const nonEmpty = rows.filter((r) => r.some((c) => txt(c))); const nonEmpty = rows.filter((r) => r.some((c) => txt(c)));
if (!nonEmpty.length) return []; 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[] = []; const out: CsvRow[] = [];
for (let i = 1; i < nonEmpty.length; i++) { for (let i = 1; i < nonEmpty.length; i++) {
const values = nonEmpty[i]; const values = nonEmpty[i];
@@ -263,9 +263,12 @@ export function processKvp(cipher: Record<string, unknown>, key: string, value:
const v = txt(value); const v = txt(value);
if (!v) return; if (!v) return;
const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array<Record<string, unknown>>) : []; const fields = Array.isArray(cipher.fields) ? (cipher.fields as Array<Record<string, unknown>>) : [];
if (fields.some((field) => txt(field.name) === k && txt(field.value) === v)) return;
if (v.length > 200 || /\r\n|\r|\n/.test(v)) { if (v.length > 200 || /\r\n|\r|\n/.test(v)) {
const existing = txt(cipher.notes); 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; return;
} }
fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null }); fields.push({ type: hidden ? 1 : 0, name: k, value: v, linkedId: null });
+25 -11
View File
@@ -1,5 +1,5 @@
import type { CiphersImportPayload } from '@/lib/api/vault'; 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 { export function parseChromeCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw); const rows = parseCsv(textRaw);
@@ -62,25 +62,37 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload {
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload { export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw); const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
const applyBitwardenCustomFields = (cipher: Record<string, unknown>, 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) { for (const row of rows) {
const type = txt(row.type).toLowerCase() || 'login'; const type = txt(row.type).toLowerCase() || 'login';
if (type === 'note') { if (type === 'note' || type === 'secure note' || type === 'securenote') {
const idx = const cipher = {
result.ciphers.push({
type: 2, type: 2,
name: val(row.name, '--'), name: val(row.name, '--'),
notes: val(row.notes), notes: val(row.notes),
favorite: txt(row.favorite) === '1', favorite: txt(row.favorite) === '1',
reprompt: 0, reprompt: Number(row.reprompt ?? 0) || 0,
key: null, key: null,
login: null, login: null,
card: null, card: null,
identity: null, identity: null,
secureNote: { type: 0 }, secureNote: { type: 0 },
fields: null, fields: [],
passwordHistory: null, passwordHistory: null,
sshKey: null, sshKey: null,
}) - 1; };
applyBitwardenCustomFields(cipher, row.fields);
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx); addFolder(result, row.folder, idx);
continue; continue;
} }
@@ -88,11 +100,13 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
cipher.name = val(row.name, '--'); cipher.name = val(row.name, '--');
cipher.notes = val(row.notes); cipher.notes = val(row.notes);
cipher.favorite = txt(row.favorite) === '1'; cipher.favorite = txt(row.favorite) === '1';
cipher.reprompt = Number(row.reprompt ?? 0) || 0;
applyBitwardenCustomFields(cipher, row.fields);
const login = cipher.login as Record<string, unknown>; const login = cipher.login as Record<string, unknown>;
login.username = val(row.login_username); login.username = val(row.login_username, val(row.username));
login.password = val(row.login_password); login.password = val(row.login_password, val(row.password));
login.totp = val(row.login_totp); login.totp = val(row.login_totp, val(row.totp));
const uris = parseSerializedUris(row.login_uri || ''); const uris = parseSerializedUris(row.login_uri || row.uri || '');
login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null; login.uris = uris.length ? uris.map((uri) => ({ uri, match: null })) : null;
const idx = result.ciphers.push(cipher) - 1; const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx); addFolder(result, row.folder, idx);
+98 -22
View File
@@ -25,14 +25,57 @@ function onePasswordTypeHints(typeName: string): 1 | 2 | 3 | 4 {
return 1; return 1;
} }
function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 { function onePasswordCategoryType(categoryUuid: string): 1 | 2 | 3 | 4 | 5 {
const c = txt(categoryUuid); const c = txt(categoryUuid);
if (['002', '101'].includes(c)) return 3; if (['002', '101'].includes(c)) return 3;
if (['004', '103', '104', '105', '106', '107', '108'].includes(c)) return 4; 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; 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<string, unknown>;
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<string, unknown>).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 { export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImportPayload {
const rows = parseCsv(textRaw); const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
@@ -87,19 +130,19 @@ export function parseOnePasswordCsv(textRaw: string, isMac: boolean): CiphersImp
if (Number(cipher.type) === 1) { if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>; const login = cipher.login as Record<string, unknown>;
if (!txt(login.username) && lower === 'username') { if (!txt(login.username) && isOnePasswordUsernameField(lower)) {
login.username = rawVal; login.username = rawVal;
continue; continue;
} }
if (!txt(login.password) && lower === 'password') { if (!txt(login.password) && isOnePasswordPasswordField(lower)) {
login.password = rawVal; login.password = rawVal;
continue; continue;
} }
if (lower === 'url' || lower === 'website') { if (isOnePasswordUriField(lower)) {
addLoginUri(login, rawVal); addLoginUri(login, rawVal);
continue; continue;
} }
if (!txt(login.totp) && isTotpFieldName(lower)) { if (!txt(login.totp) && isTotpFieldName(onePasswordCsvFieldLabel(lower))) {
login.totp = rawVal; login.totp = rawVal;
continue; 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 (!altUsername && lower === 'email') altUsername = rawVal;
if (lower === 'created date' || lower === 'modified date') { if (lower === 'created date' || lower === 'modified date') {
const readable = parseEpochMaybe(rawVal); const readable = parseEpochMaybe(rawVal);
@@ -388,6 +431,15 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
ssn: null, ssn: null,
licenseNumber: 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.favorite = Number(item?.favIndex) === 1;
cipher.name = val(item?.overview?.title, '--'); cipher.name = val(item?.overview?.title, '--');
@@ -399,9 +451,14 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
const uri = normalizeUri(u?.url || ''); const uri = normalizeUri(u?.url || '');
if (uri) urls.push(uri); if (uri) urls.push(uri);
} }
if (!urls.length) {
const fallbackUrl = normalizeUri(item?.overview?.url || ''); const fallbackUrl = normalizeUri(item?.overview?.url || '');
if (fallbackUrl) urls.push(fallbackUrl); if (fallbackUrl) urls.push(fallbackUrl);
}
setLoginUris(cipher.login as Record<string, unknown>, urls); setLoginUris(cipher.login as Record<string, unknown>, urls);
if (txt(item?.categoryUuid) === '005' && !txt((cipher.login as Record<string, unknown>).password)) {
(cipher.login as Record<string, unknown>).password = val(item?.details?.password);
}
} }
for (const loginField of item?.details?.loginFields || []) { for (const loginField of item?.details?.loginFields || []) {
@@ -409,14 +466,15 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
if (!lv) continue; if (!lv) continue;
const designation = txt(loginField?.designation).toLowerCase(); const designation = txt(loginField?.designation).toLowerCase();
const fieldName = txt(loginField?.name); const fieldName = txt(loginField?.name);
const fieldLabel = onePasswordCsvFieldLabel(fieldName || designation);
const fieldType = txt(loginField?.fieldType); const fieldType = txt(loginField?.fieldType);
if (Number(cipher.type) === 1) { if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>; const login = cipher.login as Record<string, unknown>;
if (designation === 'username') { if (designation === 'username' || isOnePasswordUsernameField(fieldLabel)) {
login.username = lv; login.username = lv;
continue; continue;
} }
if (designation === 'password') { if (designation === 'password' || isOnePasswordPasswordField(fieldLabel)) {
login.password = lv; login.password = lv;
continue; continue;
} }
@@ -432,14 +490,11 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
const fieldTitle = txt(section?.title); const fieldTitle = txt(section?.title);
for (const field of section?.fields || []) { for (const field of section?.fields || []) {
const fieldId = txt(field?.id); 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 fieldTitleLocal = txt(field?.title) || fieldTitle;
const fieldValueObj = field?.value?.value; const fieldValueObj = parsedField.raw;
let fieldValue = txt(fieldValueObj); const fieldValue = parsedField.value;
if (!fieldValue && typeof fieldValueObj === 'number') {
const iso = parseEpochMaybe(fieldValueObj);
fieldValue = iso ? new Date(iso).toUTCString() : String(fieldValueObj);
}
if (!fieldValue && !(fieldValueObj && typeof fieldValueObj === 'object')) continue; if (!fieldValue && !(fieldValueObj && typeof fieldValueObj === 'object')) continue;
if (Number(cipher.type) === 3 && cipher.card) { if (Number(cipher.type) === 3 && cipher.card) {
@@ -516,34 +571,55 @@ export function parseOnePassword1PuxJson(textRaw: string): CiphersImportPayload
} }
} else if (Number(cipher.type) === 1) { } else if (Number(cipher.type) === 1) {
const login = cipher.login as Record<string, unknown>; const login = cipher.login as Record<string, unknown>;
if (fieldId === 'url') { if (fieldId === 'url' || fieldType === 'url') {
addLoginUri(login, fieldValue); addLoginUri(login, fieldValue);
continue; continue;
} }
if (fieldId === 'username' && !txt(login.username)) { if ((fieldId === 'username' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'username') && !txt(login.username)) {
login.username = fieldValue; login.username = fieldValue;
continue; continue;
} }
if (fieldId === 'password' && !txt(login.password)) { if ((fieldId === 'password' || onePasswordCsvFieldLabel(fieldTitleLocal) === 'password') && !txt(login.password)) {
login.password = fieldValue; login.password = fieldValue;
continue; 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 ( 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) !txt(login.totp)
) { ) {
login.totp = extractTotpValue(fieldValueObj) || fieldValue; login.totp = extractTotpValue(fieldValueObj) || fieldValue;
continue; continue;
} }
} else if (Number(cipher.type) === 5 && cipher.sshKey && fieldType === 'sshkey' && fieldValueObj && typeof fieldValueObj === 'object') {
const ssh = fieldValueObj as Record<string, any>;
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); processKvp(cipher, fieldTitleLocal || fieldId || 'field', fieldValue, hidden);
} }
} }
parseOnePasswordPasswordHistory(cipher, item?.details?.passwordHistory || []);
convertToNoteIfNeeded(cipher); convertToNoteIfNeeded(cipher);
const idx = result.ciphers.push(cipher) - 1; 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);
} }
} }
} }