diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 58f889f..0ab7dc0 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -31,6 +31,7 @@ RUN apt-get update && apt-get install -y \
poppler-utils \
potrace \
python3-numpy \
+ python3-tinycss2 \
resvg \
texlive \
texlive-fonts-recommended \
diff --git a/Dockerfile b/Dockerfile
index c7e4b35..4afb7ce 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -68,6 +68,7 @@ RUN apt-get update && apt-get install -y \
poppler-utils \
potrace \
python3-numpy \
+ python3-tinycss2 \
resvg \
texlive \
texlive-fonts-recommended \
@@ -108,4 +109,4 @@ EXPOSE 3000/tcp
# used for calibre
ENV QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox"
ENV NODE_ENV=production
-ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
\ No newline at end of file
+ENTRYPOINT [ "bun", "run", "dist/src/index.js" ]
diff --git a/README.md b/README.md
index 4c6b5eb..e9e77fd 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 |
@@ -128,6 +129,8 @@ Tutorial in french:
Tutorial in chinese:
+Tutorial in polish:
+
## Screenshots

diff --git a/compose.yaml b/compose.yaml
index ca29d2b..92a4be5 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,3 +1,5 @@
+# This compose file is for development and testing purposes only. See README for production deployment instructions.
+
services:
convertx:
build:
diff --git a/mise.toml b/mise.toml
index aa28d4b..7a64ecd 100644
--- a/mise.toml
+++ b/mise.toml
@@ -2,5 +2,5 @@
bun = "1.2.2"
[env]
-JWT_SECRET = "JustForDevelopmentPurposesOnlyChangeMeInProduction!"
+JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234"
FFMPEG_OUTPUT_ARGS = "-preset veryfast -threads 2"
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");
+ });
+});