mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[PM-15483] PasswordXP-CSV-Importer: Add support for German and Dutch headers (#12216)
* Add tests to verify importing German and Dutch headers work * Add method to translate the headers from (German/Dutch into English) while the CSV data is being parsed * Report "importFormatError" when header translation did not work, instead of a generic undefined error (startsWith) * Move passwordxp-csv-importer into a dedicated folder * Introduce files with the language header translations --------- Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
d1fe72a4ab
commit
e2e9a7c345
@@ -3,10 +3,46 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
|||||||
import { PasswordXPCsvImporter } from "../src/importers";
|
import { PasswordXPCsvImporter } from "../src/importers";
|
||||||
import { ImportResult } from "../src/models/import-result";
|
import { ImportResult } from "../src/models/import-result";
|
||||||
|
|
||||||
|
import { dutchHeaders } from "./test-data/passwordxp-csv/dutch-headers";
|
||||||
|
import { germanHeaders } from "./test-data/passwordxp-csv/german-headers";
|
||||||
import { noFolder } from "./test-data/passwordxp-csv/no-folder.csv";
|
import { noFolder } from "./test-data/passwordxp-csv/no-folder.csv";
|
||||||
import { withFolders } from "./test-data/passwordxp-csv/passwordxp-with-folders.csv";
|
import { withFolders } from "./test-data/passwordxp-csv/passwordxp-with-folders.csv";
|
||||||
import { withoutFolders } from "./test-data/passwordxp-csv/passwordxp-without-folders.csv";
|
import { withoutFolders } from "./test-data/passwordxp-csv/passwordxp-without-folders.csv";
|
||||||
|
|
||||||
|
async function importLoginWithCustomFields(importer: PasswordXPCsvImporter, csvData: string) {
|
||||||
|
const result: ImportResult = await importer.parse(csvData);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const cipher = result.ciphers.shift();
|
||||||
|
expect(cipher.type).toBe(CipherType.Login);
|
||||||
|
expect(cipher.name).toBe("Title2");
|
||||||
|
expect(cipher.notes).toBe("Test Notes");
|
||||||
|
expect(cipher.login.username).toBe("Username2");
|
||||||
|
expect(cipher.login.password).toBe("12345678");
|
||||||
|
expect(cipher.login.uris[0].uri).toBe("http://URL2.com");
|
||||||
|
|
||||||
|
expect(cipher.fields.length).toBe(5);
|
||||||
|
let field = cipher.fields.shift();
|
||||||
|
expect(field.name).toBe("Account");
|
||||||
|
expect(field.value).toBe("Account2");
|
||||||
|
|
||||||
|
field = cipher.fields.shift();
|
||||||
|
expect(field.name).toBe("Modified");
|
||||||
|
expect(field.value).toBe("27-3-2024 08:11:21");
|
||||||
|
|
||||||
|
field = cipher.fields.shift();
|
||||||
|
expect(field.name).toBe("Created");
|
||||||
|
expect(field.value).toBe("27-3-2024 08:11:21");
|
||||||
|
|
||||||
|
field = cipher.fields.shift();
|
||||||
|
expect(field.name).toBe("Expire on");
|
||||||
|
expect(field.value).toBe("27-5-2024 08:11:21");
|
||||||
|
|
||||||
|
field = cipher.fields.shift();
|
||||||
|
expect(field.name).toBe("Modified by");
|
||||||
|
expect(field.value).toBe("someone");
|
||||||
|
}
|
||||||
|
|
||||||
describe("PasswordXPCsvImporter", () => {
|
describe("PasswordXPCsvImporter", () => {
|
||||||
let importer: PasswordXPCsvImporter;
|
let importer: PasswordXPCsvImporter;
|
||||||
|
|
||||||
@@ -20,6 +56,12 @@ describe("PasswordXPCsvImporter", () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return success false if CSV headers did not get translated", async () => {
|
||||||
|
const data = germanHeaders.replace("Titel;", "UnknownTitle;");
|
||||||
|
const result: ImportResult = await importer.parse(data);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should skip rows starting with >>>", async () => {
|
it("should skip rows starting with >>>", async () => {
|
||||||
const result: ImportResult = await importer.parse(noFolder);
|
const result: ImportResult = await importer.parse(noFolder);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
@@ -61,38 +103,16 @@ describe("PasswordXPCsvImporter", () => {
|
|||||||
expect(cipher.login.uris[0].uri).toBe("http://test");
|
expect(cipher.login.uris[0].uri).toBe("http://test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse CSV data and import unmapped columns as custom fields", async () => {
|
it("should parse CSV data with English headers and import unmapped columns as custom fields", async () => {
|
||||||
const result: ImportResult = await importer.parse(withoutFolders);
|
await importLoginWithCustomFields(importer, withoutFolders);
|
||||||
expect(result.success).toBe(true);
|
});
|
||||||
|
|
||||||
const cipher = result.ciphers.shift();
|
it("should parse CSV data with German headers and import unmapped columns as custom fields", async () => {
|
||||||
expect(cipher.type).toBe(CipherType.Login);
|
await importLoginWithCustomFields(importer, germanHeaders);
|
||||||
expect(cipher.name).toBe("Title2");
|
});
|
||||||
expect(cipher.notes).toBe("Test Notes");
|
|
||||||
expect(cipher.login.username).toBe("Username2");
|
|
||||||
expect(cipher.login.password).toBe("12345678");
|
|
||||||
expect(cipher.login.uris[0].uri).toBe("http://URL2.com");
|
|
||||||
|
|
||||||
expect(cipher.fields.length).toBe(5);
|
it("should parse CSV data with Dutch headers and import unmapped columns as custom fields", async () => {
|
||||||
let field = cipher.fields.shift();
|
await importLoginWithCustomFields(importer, dutchHeaders);
|
||||||
expect(field.name).toBe("Account");
|
|
||||||
expect(field.value).toBe("Account2");
|
|
||||||
|
|
||||||
field = cipher.fields.shift();
|
|
||||||
expect(field.name).toBe("Modified");
|
|
||||||
expect(field.value).toBe("27-3-2024 08:11:21");
|
|
||||||
|
|
||||||
field = cipher.fields.shift();
|
|
||||||
expect(field.name).toBe("Created");
|
|
||||||
expect(field.value).toBe("27-3-2024 08:11:21");
|
|
||||||
|
|
||||||
field = cipher.fields.shift();
|
|
||||||
expect(field.name).toBe("Expire on");
|
|
||||||
expect(field.value).toBe("27-5-2024 08:11:21");
|
|
||||||
|
|
||||||
field = cipher.fields.shift();
|
|
||||||
expect(field.name).toBe("Modified by");
|
|
||||||
expect(field.value).toBe("someone");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse CSV data with folders and assign items to them", async () => {
|
it("should parse CSV data with folders and assign items to them", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const dutchHeaders = `Titel;Gebruikersnaam;Account;URL;Wachtwoord;Gewijzigd;Gemaakt;Verloopt op;Beschrijving;Gewijzigd door
|
||||||
|
>>>
|
||||||
|
Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;27-5-2024 08:11:21;Test Notes;someone
|
||||||
|
Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;Test Notes 2;
|
||||||
|
Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;Test Notes Certicate 1;
|
||||||
|
test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;Test Notes 3;
|
||||||
|
`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const germanHeaders = `Titel;Benutzername;Konto;URL;Passwort;Geändert am;Erstellt am;Läuft ab am;Beschreibung;Geändert von
|
||||||
|
>>>
|
||||||
|
Title2;Username2;Account2;http://URL2.com;12345678;27-3-2024 08:11:21;27-3-2024 08:11:21;27-5-2024 08:11:21;Test Notes;someone
|
||||||
|
Title Test 1;Username1;Account1;http://URL1.com;Password1;27-3-2024 08:10:52;27-3-2024 08:10:52;;Test Notes 2;
|
||||||
|
Certificate 1;;;;;27-3-2024 10:22:39;27-3-2024 10:22:39;;Test Notes Certicate 1;
|
||||||
|
test;testtest;;http://test;test;27-3-2024 12:36:59;27-3-2024 12:36:59;;Test Notes 3;
|
||||||
|
`;
|
||||||
@@ -45,7 +45,7 @@ export { PasswordBossJsonImporter } from "./passwordboss-json-importer";
|
|||||||
export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer";
|
export { PasswordDragonXmlImporter } from "./passworddragon-xml-importer";
|
||||||
export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer";
|
export { PasswordSafeXmlImporter } from "./passwordsafe-xml-importer";
|
||||||
export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer";
|
export { PasswordWalletTxtImporter } from "./passwordwallet-txt-importer";
|
||||||
export { PasswordXPCsvImporter } from "./passwordxp-csv-importer";
|
export { PasswordXPCsvImporter } from "./passsordxp/passwordxp-csv-importer";
|
||||||
export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer";
|
export { ProtonPassJsonImporter } from "./protonpass/protonpass-json-importer";
|
||||||
export { PsonoJsonImporter } from "./psono/psono-json-importer";
|
export { PsonoJsonImporter } from "./psono/psono-json-importer";
|
||||||
export { RememBearCsvImporter } from "./remembear-csv-importer";
|
export { RememBearCsvImporter } from "./remembear-csv-importer";
|
||||||
|
|||||||
10
libs/importer/src/importers/passsordxp/dutch-csv-headers.ts
Normal file
10
libs/importer/src/importers/passsordxp/dutch-csv-headers.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const dutchHeaderTranslations: { [key: string]: string } = {
|
||||||
|
Titel: "Title",
|
||||||
|
Gebruikersnaam: "Username",
|
||||||
|
Wachtwoord: "Password",
|
||||||
|
Gewijzigd: "Modified",
|
||||||
|
Gemaakt: "Created",
|
||||||
|
"Verloopt op": "Expire on",
|
||||||
|
Beschrijving: "Description",
|
||||||
|
"Gewijzigd door": "Modified by",
|
||||||
|
};
|
||||||
11
libs/importer/src/importers/passsordxp/german-csv-headers.ts
Normal file
11
libs/importer/src/importers/passsordxp/german-csv-headers.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const germanHeaderTranslations: { [key: string]: string } = {
|
||||||
|
Titel: "Title",
|
||||||
|
Benutzername: "Username",
|
||||||
|
Konto: "Account",
|
||||||
|
Passwort: "Password",
|
||||||
|
"Geändert am": "Modified",
|
||||||
|
"Erstellt am": "Created",
|
||||||
|
"Läuft ab am": "Expire on",
|
||||||
|
Beschreibung: "Description",
|
||||||
|
"Geändert von": "Modified by",
|
||||||
|
};
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import { ImportResult } from "../models/import-result";
|
import { ImportResult } from "../../models/import-result";
|
||||||
|
import { BaseImporter } from "../base-importer";
|
||||||
import { BaseImporter } from "./base-importer";
|
import { Importer } from "../importer";
|
||||||
import { Importer } from "./importer";
|
|
||||||
|
|
||||||
const _mappedColumns = new Set(["Title", "Username", "URL", "Password", "Description"]);
|
const _mappedColumns = new Set(["Title", "Username", "URL", "Password", "Description"]);
|
||||||
|
import { dutchHeaderTranslations } from "./dutch-csv-headers";
|
||||||
|
import { germanHeaderTranslations } from "./german-csv-headers";
|
||||||
|
|
||||||
|
/* Translates the headers from non-English to English
|
||||||
|
* This is necessary because the parser only maps English headers to ciphers
|
||||||
|
* Currently only supports German and Dutch translations
|
||||||
|
*/
|
||||||
|
function translateIntoEnglishHeaders(header: string): string {
|
||||||
|
const translations: { [key: string]: string } = {
|
||||||
|
// The header column 'User name' is parsed by the parser, but cannot be used as a variable. This converts it to a valid variable name, prior to parsing.
|
||||||
|
"User name": "Username",
|
||||||
|
...germanHeaderTranslations,
|
||||||
|
...dutchHeaderTranslations,
|
||||||
|
};
|
||||||
|
|
||||||
|
return translations[header] || header;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PasswordXP CSV importer
|
* PasswordXP CSV importer
|
||||||
@@ -17,15 +33,22 @@ export class PasswordXPCsvImporter extends BaseImporter implements Importer {
|
|||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
parse(data: string): Promise<ImportResult> {
|
parse(data: string): Promise<ImportResult> {
|
||||||
// The header column 'User name' is parsed by the parser, but cannot be used as a variable. This converts it to a valid variable name, prior to parsing.
|
|
||||||
data = data.replace(";User name;", ";Username;");
|
|
||||||
|
|
||||||
const result = new ImportResult();
|
const result = new ImportResult();
|
||||||
const results = this.parseCsv(data, true, { skipEmptyLines: true });
|
const results = this.parseCsv(data, true, {
|
||||||
|
skipEmptyLines: true,
|
||||||
|
transformHeader: translateIntoEnglishHeaders,
|
||||||
|
});
|
||||||
if (results == null) {
|
if (results == null) {
|
||||||
result.success = false;
|
result.success = false;
|
||||||
return Promise.resolve(result);
|
return Promise.resolve(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the first row (header check) does not contain the column "Title", then the data is invalid (no translation found)
|
||||||
|
if (!results[0].Title) {
|
||||||
|
result.success = false;
|
||||||
|
return Promise.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
let currentFolderName = "";
|
let currentFolderName = "";
|
||||||
results.forEach((row) => {
|
results.forEach((row) => {
|
||||||
// Skip rows starting with '>>>' as they indicate items following have no folder assigned to them
|
// Skip rows starting with '>>>' as they indicate items following have no folder assigned to them
|
||||||
Reference in New Issue
Block a user