code icon Code

Parse vCard

Parse vCard contact files into structured data

Source Code

import fs from "fs";
import path from "path";

const [inputPath, outputPath] = process.argv.slice(2);

if (!inputPath || !outputPath) {
  console.error("Usage: inputPath outputPath");
  process.exit(1);
}

/**
 * Parse a single vCard block into structured data
 */
function parseVCard(vcardText) {
  const contact = {
    name: null,
    firstName: null,
    lastName: null,
    organization: null,
    title: null,
    emails: [],
    phones: [],
    addresses: [],
    urls: [],
    notes: null,
    birthday: null,
  };

  const lines = vcardText.split(/\r?\n/);
  let currentLine = "";

  for (const line of lines) {
    // Handle line folding (lines starting with space/tab are continuations)
    if (line.startsWith(" ") || line.startsWith("\t")) {
      currentLine += line.slice(1);
      continue;
    }

    if (currentLine) {
      processLine(currentLine, contact);
    }
    currentLine = line;
  }

  if (currentLine) {
    processLine(currentLine, contact);
  }

  return contact;
}

/**
 * Process a single vCard property line
 */
function processLine(line, contact) {
  const colonIndex = line.indexOf(":");
  if (colonIndex === -1) return;

  const propertyPart = line.slice(0, colonIndex);
  const value = line.slice(colonIndex + 1);

  // Parse property name and parameters
  const [propName, ...params] = propertyPart.split(";");
  const upperProp = propName.toUpperCase();

  // Parse parameters into object
  const paramObj = {};
  for (const param of params) {
    const [key, val] = param.split("=");
    paramObj[key?.toUpperCase()] = val;
  }

  switch (upperProp) {
    case "FN":
      contact.name = decodeValue(value);
      break;

    case "N":
      const nameParts = value.split(";");
      contact.lastName = decodeValue(nameParts[0]) || null;
      contact.firstName = decodeValue(nameParts[1]) || null;
      break;

    case "ORG":
      contact.organization = decodeValue(value.split(";")[0]);
      break;

    case "TITLE":
      contact.title = decodeValue(value);
      break;

    case "EMAIL":
      contact.emails.push({
        value: decodeValue(value),
        type: paramObj.TYPE || "other",
      });
      break;

    case "TEL":
      contact.phones.push({
        value: decodeValue(value),
        type: paramObj.TYPE || "other",
      });
      break;

    case "ADR":
      const addrParts = value.split(";");
      contact.addresses.push({
        type: paramObj.TYPE || "other",
        street: decodeValue(addrParts[2]) || null,
        city: decodeValue(addrParts[3]) || null,
        state: decodeValue(addrParts[4]) || null,
        zip: decodeValue(addrParts[5]) || null,
        country: decodeValue(addrParts[6]) || null,
      });
      break;

    case "URL":
      contact.urls.push(decodeValue(value));
      break;

    case "NOTE":
      contact.notes = decodeValue(value);
      break;

    case "BDAY":
      contact.birthday = value;
      break;
  }
}

/**
 * Decode vCard encoded values
 */
function decodeValue(value) {
  if (!value) return null;

  // Handle quoted-printable encoding
  value = value.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));

  // Handle escaped characters
  value = value.replace(/\\n/gi, "\n").replace(/\\,/g, ",").replace(/\\;/g, ";").replace(/\\\\/g, "\\");

  return value.trim();
}

try {
  console.log(`Parsing vCard: ${inputPath}...`);
  const vcfContent = fs.readFileSync(inputPath, "utf-8");

  // Split into individual vCards
  const vcardBlocks = vcfContent.split(/(?=BEGIN:VCARD)/i).filter((block) => block.trim().toUpperCase().startsWith("BEGIN:VCARD"));

  console.log(`  Found ${vcardBlocks.length} contacts`);

  const contacts = vcardBlocks.map(parseVCard);

  // Ensure output directory exists
  const dir = path.dirname(outputPath);
  if (dir && dir !== ".") {
    fs.mkdirSync(dir, { recursive: true });
  }

  fs.writeFileSync(outputPath, JSON.stringify(contacts, null, 2));

  console.log(`\nāœ“ Parsed vCard`);
  console.log(`  Contacts: ${contacts.length}`);
  console.log(`  Written to: ${outputPath}`);

  console.log(
    JSON.stringify({
      success: true,
      inputPath,
      outputPath,
      contactCount: contacts.length,
    })
  );
} catch (error) {
  console.error("Error:", error.message);
  process.exit(1);
}