diff --git a/index.html b/index.html index 0ec8628..dadccff 100644 --- a/index.html +++ b/index.html @@ -14,10 +14,15 @@ - - - + + + + + + + + diff --git a/script.js b/script.js index 3ebdeb7..1568c54 100644 --- a/script.js +++ b/script.js @@ -6,58 +6,124 @@ document.getElementById('vcardFile').addEventListener('change', function(event) const rawContent = e.target.result; const contacts = parseVCard(rawContent); displayContactsInTable(contacts); - // For debugging, also show raw content - // const vcardContentDiv = document.getElementById('vcardRawContent'); - // vcardContentDiv.textContent = rawContent; }; - reader.readAsText(file); + reader.readAsText(file); // Defaulting to UTF-8, VCard CHARSET can override } }); +function decodeQuotedPrintable(str, charset = 'UTF-8') { + // Handle soft line breaks (= at the end of a line) + str = str.replace(/=\r?\n/g, ''); + + // Decode =XX hex sequences + try { + const bytes = []; + for (let i = 0; i < str.length; i++) { + if (str[i] === '=' && str[i+1] && str[i+2]) { + const hex = str.substring(i+1, i+3); + bytes.push(parseInt(hex, 16)); + i += 2; + } else { + bytes.push(str.charCodeAt(i)); + } + } + const byteArray = new Uint8Array(bytes); + // Standard charsets like UTF-8, ISO-8859-1 are well supported + // For others, TextDecoder might not work or might need a polyfill + return new TextDecoder(charset).decode(byteArray); + } catch (e) { + console.error("Error decoding quoted-printable string:", e); + // Fallback to a simpler replacement if TextDecoder fails or for basic cases + return str.replace(/=([A-F0-9]{2})/g, (match, p1) => String.fromCharCode(parseInt(p1, 16))); + } +} + + function parseVCard(rawContent) { const contacts = []; - const lines = rawContent.split(/\r\n|\r|\n/); + // Handle line folding: join lines that start with a space or tab + const foldedLines = rawContent.replace(/\r?\n[ \t]/g, ''); + const lines = foldedLines.split(/\r?\n|\r|\n/); + let currentContact = null; lines.forEach(line => { + if (line.trim() === '') return; if (line.toUpperCase() === 'BEGIN:VCARD') { - currentContact = {}; + currentContact = { + tel: [], + email: [], + adr: [], + other: {} // For all other properties + }; } else if (line.toUpperCase() === 'END:VCARD') { if (currentContact) { contacts.push(currentContact); currentContact = null; } } else if (currentContact) { - const parts = line.split(':'); - if (parts.length >= 2) { - const keyPart = parts[0]; - const value = parts.slice(1).join(':'); + let [fullKey, ...valueParts] = line.split(':'); + let value = valueParts.join(':'); - // Basic parsing for common fields - // FN (Formatted Name) - if (keyPart.startsWith('FN')) { + const keyParts = fullKey.split(';'); + const mainKey = keyParts[0].toUpperCase(); + const params = {}; + keyParts.slice(1).forEach(p => { + const [paramName, paramValue] = p.split('='); + params[paramName.toUpperCase()] = paramValue; + }); + + // Check for Quoted-Printable encoding + if (params['ENCODING'] === 'QUOTED-PRINTABLE') { + const charset = params['CHARSET'] || 'UTF-8'; + value = decodeQuotedPrintable(value, charset); + } + + switch (mainKey) { + case 'FN': currentContact.fn = value; - } - // TEL (Telephone) - else if (keyPart.startsWith('TEL')) { - // Simplistic approach: take the first TEL found - if (!currentContact.tel) currentContact.tel = value; - } - // EMAIL - else if (keyPart.startsWith('EMAIL')) { - // Simplistic approach: take the first EMAIL found - if (!currentContact.email) currentContact.email = value; - } - // ORG (Organization) - else if (keyPart.startsWith('ORG')) { - currentContact.org = value; - } + break; + case 'N': + // N:Family;Given;Middle;Prefix;Suffix + const nameParts = value.split(';'); + currentContact.n = { + family: nameParts[0] || '', + given: nameParts[1] || '', + middle: nameParts[2] || '', + prefix: nameParts[3] || '', + suffix: nameParts[4] || '' + }; + break; + case 'TEL': + currentContact.tel.push({ value: value, params: params }); + break; + case 'EMAIL': + currentContact.email.push({ value: value, params: params }); + break; + case 'ORG': + currentContact.org = value.split(';')[0]; // Organization name, ignore department for now + break; + case 'ADR': + currentContact.adr.push({ value: value, params: params }); + break; + // Add more common cases if needed, or they fall into 'other' + default: + if (currentContact.other[mainKey]) { + if (!Array.isArray(currentContact.other[mainKey])) { + currentContact.other[mainKey] = [currentContact.other[mainKey]]; + } + currentContact.other[mainKey].push({ value: value, params: params }); + } else { + currentContact.other[mainKey] = { value: value, params: params }; + } + break; } } }); return contacts; } + function displayContactsInTable(contacts) { const tableBody = document.getElementById('contactsTableBody'); if (!tableBody) { @@ -66,19 +132,65 @@ function displayContactsInTable(contacts) { } tableBody.innerHTML = ''; // Clear previous entries - if (contacts.length === 0) { + if (!contacts || contacts.length === 0) { const row = tableBody.insertRow(); const cell = row.insertCell(); - cell.colSpan = 4; // Number of columns - cell.textContent = 'No contacts found in the VCard file.'; + // Adjust colSpan when table structure is finalized in index.html + cell.colSpan = 9; // Placeholder, update with actual number of columns + cell.textContent = 'No contacts found or VCard is empty/invalid.'; return; } contacts.forEach(contact => { const row = tableBody.insertRow(); row.insertCell().textContent = contact.fn || 'N/A'; - row.insertCell().textContent = contact.tel || 'N/A'; - row.insertCell().textContent = contact.email || 'N/A'; + + // Structured Name (N) + row.insertCell().textContent = contact.n ? contact.n.family : 'N/A'; + row.insertCell().textContent = contact.n ? contact.n.given : 'N/A'; + + // Phones - display first two, with types if available + for (let i = 0; i < 2; i++) { + const telCell = row.insertCell(); + if (contact.tel && contact.tel[i]) { + let telStr = contact.tel[i].value; + const type = contact.tel[i].params['TYPE']; + if (type) telStr += ` (${type.split(',').join('/')})`; // Display multiple types e.g. (HOME/VOICE) + telCell.textContent = telStr; + } else { + telCell.textContent = 'N/A'; + } + } + + // Emails - display first two, with types if available + for (let i = 0; i < 2; i++) { + const emailCell = row.insertCell(); + if (contact.email && contact.email[i]) { + let emailStr = contact.email[i].value; + const type = contact.email[i].params['TYPE']; + if (type) emailStr += ` (${type.split(',').join('/')})`; + emailCell.textContent = emailStr; + } else { + emailCell.textContent = 'N/A'; + } + } + row.insertCell().textContent = contact.org || 'N/A'; + + // Other Properties + const otherCell = row.insertCell(); + let otherText = ''; + for (const key in contact.other) { + const prop = contact.other[key]; + if (Array.isArray(prop)) { + prop.forEach(pItem => { + otherText += `${key}: ${pItem.value} (${JSON.stringify(pItem.params)})\n`; + }); + } else { + otherText += `${key}: ${prop.value} (${JSON.stringify(prop.params)})\n`; + } + } + otherCell.textContent = otherText.trim() || 'N/A'; + otherCell.style.whiteSpace = 'pre-wrap'; // To respect newlines }); }
NamePhoneEmailFormatted Name (FN)Family NameGiven NamePhone 1Phone 2Email 1Email 2 OrganizationOther Properties