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:
parent
bbf6f6ba52
commit
d22f6ad213
4 changed files with 172 additions and 0 deletions
|
|
@ -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 |
|
| [Dasel](https://github.com/TomWright/dasel) | Data Files | 5 | 4 |
|
||||||
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
|
| [Pandoc](https://pandoc.org/) | Documents | 43 | 65 |
|
||||||
| [msgconvert](https://github.com/mvz/email-outlook-message-perl) | Outlook | 1 | 1 |
|
| [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 |
|
| [dvisvgm](https://dvisvgm.de/) | Vector images | 4 | 2 |
|
||||||
| [ImageMagick](https://imagemagick.org/) | Images | 245 | 183 |
|
| [ImageMagick](https://imagemagick.org/) | Images | 245 | 183 |
|
||||||
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 167 | 130 |
|
| [GraphicsMagick](http://www.graphicsmagick.org/) | Images | 167 | 130 |
|
||||||
|
|
|
||||||
|
|
@ -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 convertresvg, properties as propertiesresvg } from "./resvg";
|
||||||
import { convert as convertImage, properties as propertiesImage } from "./vips";
|
import { convert as convertImage, properties as propertiesImage } from "./vips";
|
||||||
import { convert as convertVtracer, properties as propertiesVtracer } from "./vtracer";
|
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 convertxelatex, properties as propertiesxelatex } from "./xelatex";
|
||||||
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
||||||
|
|
||||||
|
|
@ -128,6 +129,10 @@ const properties: Record<
|
||||||
properties: propertiesVtracer,
|
properties: propertiesVtracer,
|
||||||
converter: convertVtracer,
|
converter: convertVtracer,
|
||||||
},
|
},
|
||||||
|
vcf: {
|
||||||
|
properties: propertiesVcf,
|
||||||
|
converter: convertVcf,
|
||||||
|
},
|
||||||
markitDown: {
|
markitDown: {
|
||||||
properties: propertiesMarkitdown,
|
properties: propertiesMarkitdown,
|
||||||
converter: convertMarkitdown,
|
converter: convertMarkitdown,
|
||||||
|
|
|
||||||
63
src/converters/vcf.ts
Normal file
63
src/converters/vcf.ts
Normal file
|
|
@ -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<string, string>[] {
|
||||||
|
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<string, string> = {};
|
||||||
|
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, string>[]): 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<string> {
|
||||||
|
const vcfData = await readFile(filePath, 'utf-8');
|
||||||
|
const contacts = parseVCF(vcfData);
|
||||||
|
const csvData = toCSV(contacts);
|
||||||
|
await writeFile(targetPath, csvData, 'utf-8');
|
||||||
|
return "Done";
|
||||||
|
}
|
||||||
103
tests/converters/vcf.test.ts
Normal file
103
tests/converters/vcf.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue