feat: add VCF to CSV converter

Add support for converting VCF contact files to CSV format
- Implement TypeScript-based VCF parsing without external dependencies
- Create CSV export with proper quote escaping
- Add comprehensive test coverage for various VCF formats
- Update documentation and converter registry

Closes #396
This commit is contained in:
Kunal Singh 2026-01-11 13:10:50 +05:30
parent d22f6ad213
commit ddb8de4f96
2 changed files with 58 additions and 57 deletions

View file

@ -10,42 +10,48 @@ export const properties = {
}; };
export function parseVCF(data: string): Record<string, string>[] { export function parseVCF(data: string): Record<string, string>[] {
const cards = data.split(/BEGIN:VCARD/).slice(1).map(card => card.split(/END:VCARD/)[0]).filter(card => card); const cards = data
return cards.map(card => { .split(/BEGIN:VCARD/)
if (!card) return {}; .slice(1)
const lines = card.split('\n').filter(line => line.trim()); .map((card) => card.split(/END:VCARD/)[0])
const contact: Record<string, string> = {}; .filter((card) => card);
for (const line of lines) { return cards
const colonIndex = line.indexOf(':'); .map((card) => {
if (colonIndex === -1) continue; if (!card) return {};
const key = line.slice(0, colonIndex).trim(); const lines = card.split("\n").filter((line) => line.trim());
const value = line.slice(colonIndex + 1).trim(); const contact: Record<string, string> = {};
if (key === 'FN') { for (const line of lines) {
contact['Full Name'] = value; const colonIndex = line.indexOf(":");
} else if (key === 'N') { if (colonIndex === -1) continue;
const parts = value.split(';'); const key = line.slice(0, colonIndex).trim();
contact['Last Name'] = parts[0] || ''; const value = line.slice(colonIndex + 1).trim();
contact['First Name'] = parts[1] || ''; if (key === "FN") {
} else if (key.startsWith('TEL')) { contact["Full Name"] = value;
contact['Phone'] = value; } else if (key === "N") {
} else if (key.startsWith('EMAIL')) { const parts = value.split(";");
contact['Email'] = value; contact["Last Name"] = parts[0] || "";
} else if (key === 'ORG') { contact["First Name"] = parts[1] || "";
contact['Organization'] = value.split(';')[0] || ''; } else if (key.startsWith("TEL")) {
contact["Phone"] = value;
} else if (key.startsWith("EMAIL")) {
contact["Email"] = value;
} else if (key === "ORG") {
contact["Organization"] = value.split(";")[0] || "";
}
} }
} return contact;
return contact; })
}).filter(contact => Object.keys(contact).length > 0); .filter((contact) => Object.keys(contact).length > 0);
} }
export function toCSV(data: Record<string, string>[]): string { export function toCSV(data: Record<string, string>[]): string {
if (!data.length) return ''; if (!data.length) return "";
const first = data[0]; const first = data[0];
if (!first) return ''; if (!first) return "";
const headers = Object.keys(first); const headers = Object.keys(first);
const escape = (str: string) => `"${str.replace(/"/g, '""')}"`; const escape = (str: string) => `"${str.replace(/"/g, '""')}"`;
const rows = data.map(row => headers.map(h => escape(row[h] || '')).join(',')); const rows = data.map((row) => headers.map((h) => escape(row[h] || "")).join(","));
return [headers.join(','), ...rows].join('\n'); return [headers.join(","), ...rows].join("\n");
} }
export async function convert( export async function convert(
@ -53,11 +59,11 @@ export async function convert(
fileType: string, fileType: string,
convertTo: string, convertTo: string,
targetPath: string, targetPath: string,
options?: unknown, options?: unknown, // eslint-disable-line @typescript-eslint/no-unused-vars
): Promise<string> { ): Promise<string> {
const vcfData = await readFile(filePath, 'utf-8'); const vcfData = await readFile(filePath, "utf-8");
const contacts = parseVCF(vcfData); const contacts = parseVCF(vcfData);
const csvData = toCSV(contacts); const csvData = toCSV(contacts);
await writeFile(targetPath, csvData, 'utf-8'); await writeFile(targetPath, csvData, "utf-8");
return "Done"; return "Done";
} }

View file

@ -16,13 +16,13 @@ END:VCARD`;
expect(result).toEqual([ expect(result).toEqual([
{ {
'Full Name': 'John Doe', "Full Name": "John Doe",
'Last Name': 'Doe', "Last Name": "Doe",
'First Name': 'John', "First Name": "John",
'Phone': '+123456789', Phone: "+123456789",
'Email': 'john@example.com', Email: "john@example.com",
'Organization': 'Example Corp' Organization: "Example Corp",
} },
]); ]);
}); });
@ -36,10 +36,7 @@ END:VCARD`;
const result = parseVCF(vcfData); const result = parseVCF(vcfData);
expect(result).toEqual([ expect(result).toEqual([{ "Full Name": "John Doe" }, { "Full Name": "Jane Smith" }]);
{ 'Full Name': 'John Doe' },
{ 'Full Name': 'Jane Smith' }
]);
}); });
test("should parse VCF with TYPE parameters", () => { test("should parse VCF with TYPE parameters", () => {
@ -55,12 +52,12 @@ END:VCARD`;
expect(result).toEqual([ expect(result).toEqual([
{ {
'Full Name': 'John Doe', "Full Name": "John Doe",
'Last Name': 'Doe', "Last Name": "Doe",
'First Name': 'John', "First Name": "John",
'Phone': '(111) 555-1212', Phone: "(111) 555-1212",
'Email': 'john.doe@example.com' Email: "john.doe@example.com",
} },
]); ]);
}); });
}); });
@ -69,10 +66,10 @@ describe("toCSV", () => {
test("should convert contacts to CSV", () => { test("should convert contacts to CSV", () => {
const contacts = [ const contacts = [
{ {
'Full Name': 'John Doe', "Full Name": "John Doe",
'Phone': '+123', Phone: "+123",
'Email': 'john@example.com' Email: "john@example.com",
} },
]; ];
const result = toCSV(contacts); const result = toCSV(contacts);
@ -81,9 +78,7 @@ describe("toCSV", () => {
}); });
test("should escape quotes", () => { test("should escape quotes", () => {
const contacts = [ const contacts = [{ "Full Name": 'John "Johnny" Doe' }];
{ 'Full Name': 'John "Johnny" Doe' }
];
const result = toCSV(contacts); const result = toCSV(contacts);
@ -92,7 +87,7 @@ describe("toCSV", () => {
test("should handle empty data", () => { test("should handle empty data", () => {
const result = toCSV([]); const result = toCSV([]);
expect(result).toBe(''); expect(result).toBe("");
}); });
}); });