diff --git a/README.md b/README.md index e99ea73..7822aa0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ A self-hosted online file converter. Supports over a thousand different formats. | [Dasel](https://github.com/TomWright/dasel) | Data Files | 5 | 4 | | [Pandoc](https://pandoc.org/) | Documents | 43 | 65 | | [msgconvert](https://github.com/mvz/email-outlook-message-perl) | Outlook | 1 | 1 | +| VCF to CSV | Contacts | 1 | 1 | | [dvisvgm](https://dvisvgm.de/) | Vector images | 4 | 2 | | [ImageMagick](https://imagemagick.org/) | Images | 245 | 183 | | [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 167 | 130 | diff --git a/src/converters/main.ts b/src/converters/main.ts index d9ec8e8..cee3a30 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -22,6 +22,7 @@ import { convert as convertPotrace, properties as propertiesPotrace } from "./po import { convert as convertresvg, properties as propertiesresvg } from "./resvg"; import { convert as convertImage, properties as propertiesImage } from "./vips"; import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer"; +import { convert as convertVcf, properties as propertiesVcf } from "./vcf"; import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex"; import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown"; @@ -128,6 +129,10 @@ const properties: Record< properties: propertiesVtracer, converter: convertVtracer, }, + vcf: { + properties: propertiesVcf, + converter: convertVcf, + }, markitDown: { properties: propertiesMarkitdown, converter: convertMarkitdown, diff --git a/src/converters/vcf.ts b/src/converters/vcf.ts new file mode 100644 index 0000000..e5da423 --- /dev/null +++ b/src/converters/vcf.ts @@ -0,0 +1,63 @@ +import { readFile, writeFile } from "fs/promises"; + +export const properties = { + from: { + contacts: ["vcf"], + }, + to: { + contacts: ["csv"], + }, +}; + +export function parseVCF(data: string): Record[] { + const cards = data.split(/BEGIN:VCARD/).slice(1).map(card => card.split(/END:VCARD/)[0]).filter(card => card); + return cards.map(card => { + if (!card) return {}; + const lines = card.split('\n').filter(line => line.trim()); + const contact: Record = {}; + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + if (key === 'FN') { + contact['Full Name'] = value; + } else if (key === 'N') { + const parts = value.split(';'); + contact['Last Name'] = parts[0] || ''; + contact['First Name'] = parts[1] || ''; + } 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; + }).filter(contact => Object.keys(contact).length > 0); +} + +export function toCSV(data: Record[]): string { + if (!data.length) return ''; + const first = data[0]; + if (!first) return ''; + const headers = Object.keys(first); + const escape = (str: string) => `"${str.replace(/"/g, '""')}"`; + const rows = data.map(row => headers.map(h => escape(row[h] || '')).join(',')); + return [headers.join(','), ...rows].join('\n'); +} + +export async function convert( + filePath: string, + fileType: string, + convertTo: string, + targetPath: string, + options?: unknown, +): Promise { + const vcfData = await readFile(filePath, 'utf-8'); + const contacts = parseVCF(vcfData); + const csvData = toCSV(contacts); + await writeFile(targetPath, csvData, 'utf-8'); + return "Done"; +} diff --git a/tests/converters/vcf.test.ts b/tests/converters/vcf.test.ts new file mode 100644 index 0000000..189beac --- /dev/null +++ b/tests/converters/vcf.test.ts @@ -0,0 +1,103 @@ +import { expect, test, describe } from "bun:test"; +import { convert, parseVCF, toCSV } from "../../src/converters/vcf"; + +describe("parseVCF", () => { + test("should parse a simple VCF card", () => { + const vcfData = `BEGIN:VCARD +VERSION:3.0 +FN:John Doe +N:Doe;John;;; +TEL:+123456789 +EMAIL:john@example.com +ORG:Example Corp +END:VCARD`; + + const result = parseVCF(vcfData); + + expect(result).toEqual([ + { + 'Full Name': 'John Doe', + 'Last Name': 'Doe', + 'First Name': 'John', + 'Phone': '+123456789', + 'Email': 'john@example.com', + 'Organization': 'Example Corp' + } + ]); + }); + + test("should handle multiple cards", () => { + const vcfData = `BEGIN:VCARD +FN:John Doe +END:VCARD +BEGIN:VCARD +FN:Jane Smith +END:VCARD`; + + const result = parseVCF(vcfData); + + expect(result).toEqual([ + { 'Full Name': 'John Doe' }, + { 'Full Name': 'Jane Smith' } + ]); + }); + + test("should parse VCF with TYPE parameters", () => { + const vcfData = `BEGIN:VCARD +VERSION:3.0 +FN:John Doe +N:Doe;John;;; +TEL;TYPE=WORK,VOICE:(111) 555-1212 +EMAIL;TYPE=PREF,INTERNET:john.doe@example.com +END:VCARD`; + + const result = parseVCF(vcfData); + + expect(result).toEqual([ + { + 'Full Name': 'John Doe', + 'Last Name': 'Doe', + 'First Name': 'John', + 'Phone': '(111) 555-1212', + 'Email': 'john.doe@example.com' + } + ]); + }); +}); + +describe("toCSV", () => { + test("should convert contacts to CSV", () => { + const contacts = [ + { + 'Full Name': 'John Doe', + 'Phone': '+123', + 'Email': 'john@example.com' + } + ]; + + const result = toCSV(contacts); + + expect(result).toBe('Full Name,Phone,Email\n"John Doe","+123","john@example.com"'); + }); + + test("should escape quotes", () => { + const contacts = [ + { 'Full Name': 'John "Johnny" Doe' } + ]; + + const result = toCSV(contacts); + + expect(result).toBe('Full Name\n"John ""Johnny"" Doe"'); + }); + + test("should handle empty data", () => { + const result = toCSV([]); + expect(result).toBe(''); + }); +}); + +describe("convert", () => { + test("should be a function", () => { + expect(typeof convert).toBe("function"); + }); +});