/** * Parse vCard (VCF) format and extract contact information * Supports vCard 2.1, 3.0, and 4.0 formats */ /** * Parse a vCard file and return structured contact data * @param {string} vcfText - The raw vCard text content * @returns {Object} Parsed contact information */ export function parseVCard(vcfText) { const contact = { version: '', name: '', formattedName: '', phoneNumbers: [], emails: [], addresses: [], organization: '', title: '', photo: null, birthday: '', url: '', note: '' } // Split into lines and handle line folding (continuation lines starting with space/tab) const lines = unfoldLines(vcfText) for (const line of lines) { const [property, value] = parseVCardLine(line) if (!property) continue const { name, params } = parseProperty(property) switch (name.toUpperCase()) { case 'VERSION': contact.version = value break case 'FN': contact.formattedName = decodeValue(value, params) break case 'N': // N:LastName;FirstName;MiddleName;Prefix;Suffix const nameParts = value.split(';').map(p => decodeValue(p, params)) if (!contact.name) { contact.name = [nameParts[3], nameParts[1], nameParts[2], nameParts[0], nameParts[4]] .filter(p => p) .join(' ') } break case 'TEL': contact.phoneNumbers.push({ type: getTypeLabel(params, 'phone'), number: value }) break case 'EMAIL': contact.emails.push({ type: getTypeLabel(params, 'email'), address: value }) break case 'ADR': // ADR:;;Street;City;State;ZIP;Country const adrParts = value.split(';').map(p => decodeValue(p, params)) const address = { type: getTypeLabel(params, 'address'), street: adrParts[2], city: adrParts[3], state: adrParts[4], zip: adrParts[5], country: adrParts[6] } contact.addresses.push(address) break case 'ORG': contact.organization = decodeValue(value, params) break case 'TITLE': contact.title = decodeValue(value, params) break case 'PHOTO': contact.photo = parsePhoto(value, params) break case 'BDAY': contact.birthday = value break case 'URL': contact.url = value break case 'NOTE': contact.note = decodeValue(value, params) break } } // Use formatted name if name is empty if (!contact.name && contact.formattedName) { contact.name = contact.formattedName } return contact } /** * Unfold lines (handle line continuation in vCard format) */ function unfoldLines(text) { const lines = text.split(/\r?\n/) const unfolded = [] let current = '' for (const line of lines) { // Line continuation: starts with space or tab if (line.startsWith(' ') || line.startsWith('\t')) { current += line.substring(1) } else { if (current) { unfolded.push(current) } current = line } } if (current) { unfolded.push(current) } return unfolded } /** * Parse a vCard line into property and value */ function parseVCardLine(line) { const colonIndex = line.indexOf(':') if (colonIndex === -1) return [null, null] const property = line.substring(0, colonIndex) const value = line.substring(colonIndex + 1) return [property, value] } /** * Parse property name and parameters * Example: "TEL;TYPE=CELL;PREF=1" => { name: "TEL", params: { TYPE: "CELL", PREF: "1" } } */ function parseProperty(property) { const parts = property.split(';') const name = parts[0] const params = {} for (let i = 1; i < parts.length; i++) { const param = parts[i] const eqIndex = param.indexOf('=') if (eqIndex === -1) { // vCard 2.1 style: TYPE without = params['TYPE'] = params['TYPE'] ? params['TYPE'] + ',' + param : param } else { const paramName = param.substring(0, eqIndex) const paramValue = param.substring(eqIndex + 1).replace(/^"(.*)"$/, '$1') params[paramName.toUpperCase()] = paramValue } } return { name, params } } /** * Get human-readable type label from parameters */ function getTypeLabel(params, context) { if (!params.TYPE) { return context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address' } const types = params.TYPE.split(',').map(t => t.toUpperCase()) // Common type mappings const typeMap = { 'CELL': 'Mobile', 'HOME': 'Home', 'WORK': 'Work', 'VOICE': 'Phone', 'FAX': 'Fax', 'PAGER': 'Pager', 'MSG': 'Message', 'PREF': 'Preferred', 'INTERNET': 'Email' } const labels = types .map(t => typeMap[t] || t.charAt(0) + t.substring(1).toLowerCase()) .filter(l => l !== 'Internet') // Remove generic Internet label return labels.join(', ') || (context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address') } /** * Decode value based on encoding parameter */ function decodeValue(value, params) { if (!params.ENCODING) return value const encoding = params.ENCODING.toUpperCase() if (encoding === 'QUOTED-PRINTABLE') { return decodeQuotedPrintable(value) } return value } /** * Decode quoted-printable encoding */ function decodeQuotedPrintable(str) { return str .replace(/=\r?\n/g, '') // Remove soft line breaks .replace(/=([0-9A-F]{2})/gi, (match, hex) => String.fromCharCode(parseInt(hex, 16))) } /** * Parse photo data from vCard */ function parsePhoto(value, params) { const encoding = params.ENCODING ? params.ENCODING.toUpperCase() : '' const type = params.TYPE || params.MEDIATYPE || 'JPEG' if (encoding === 'BASE64' || encoding === 'B') { // Remove whitespace from base64 data const base64Data = value.replace(/\s/g, '') // Determine MIME type let mimeType = 'image/jpeg' const typeUpper = type.toUpperCase() if (typeUpper.includes('PNG')) { mimeType = 'image/png' } else if (typeUpper.includes('GIF')) { mimeType = 'image/gif' } else if (typeUpper.includes('BMP')) { mimeType = 'image/bmp' } return `data:${mimeType};base64,${base64Data}` } // URL reference if (value.startsWith('http://') || value.startsWith('https://')) { return value } return null } /** * Format address object to string */ export function formatAddress(address) { const parts = [ address.street, address.city, address.state && address.zip ? `${address.state} ${address.zip}` : address.state || address.zip, address.country ].filter(p => p) return parts.join(', ') } /** * Format birthday to readable format */ export function formatBirthday(birthday) { if (!birthday) return '' // Handle different date formats // YYYYMMDD, YYYY-MM-DD, or --MMDD (no year) if (birthday.startsWith('--')) { const month = birthday.substring(2, 4) const day = birthday.substring(4, 6) return `${month}/${day}` } if (birthday.includes('-')) { const [year, month, day] = birthday.split('-') return `${month}/${day}/${year}` } if (birthday.length === 8) { const year = birthday.substring(0, 4) const month = birthday.substring(4, 6) const day = birthday.substring(6, 8) return `${month}/${day}/${year}` } return birthday }