mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance OnePassword CSV parsing with improved field handling and new category type support
This commit is contained in:
@@ -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<string, unknown>, key: string, value:
|
||||
const v = txt(value);
|
||||
if (!v) return;
|
||||
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)) {
|
||||
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 });
|
||||
|
||||
@@ -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<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) {
|
||||
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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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<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 {
|
||||
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<string, unknown>;
|
||||
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<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 || []) {
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user