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..2fff8ed --- /dev/null +++ b/src/converters/vcf.ts @@ -0,0 +1,69 @@ +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, // eslint-disable-line @typescript-eslint/no-unused-vars +): 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..181dd71 --- /dev/null +++ b/tests/converters/vcf.test.ts @@ -0,0 +1,98 @@ +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"); + }); +});