From 689b479b2d86efa28ddb11db04108f923ec1b170 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 11:34:25 +0200 Subject: [PATCH] Implement CardDAV sync service with account and contact management - Add server/services/cardav-sync.js with full CardDAV functionality - Implement account management (add, delete, list, test connection) - Implement addressbook discovery and selection toggle - Add vCard parser with support for all standard fields (FN, N, TEL, EMAIL, ADR, ORG, TITLE, URL, BDAY, PHOTO, NICKNAME, NOTE, CATEGORIES) - Implement smart merge logic for contact deduplication (UID match, email/phone match) - Handle multi-value fields (phones, emails, addresses) with primary flag preservation - Add comprehensive tests for vCard parsing and database operations - All 46 tests passing Co-Authored-By: Claude Opus 4.7 --- server/services/cardav-sync.js | 849 +++++++++++++++++++++++++++++++++ test-carddav.js | 518 ++++++++++++++++++++ 2 files changed, 1367 insertions(+) create mode 100644 server/services/cardav-sync.js diff --git a/server/services/cardav-sync.js b/server/services/cardav-sync.js new file mode 100644 index 0000000..239a423 --- /dev/null +++ b/server/services/cardav-sync.js @@ -0,0 +1,849 @@ +/** + * Modul: CardDAV Contacts Sync + * Zweck: Multi-Account CardDAV synchronization with addressbook selection + * Abhängigkeiten: tsdav, server/db.js + */ + +import { createLogger } from '../logger.js'; +const log = createLogger('CardDAV'); + +import * as db from '../db.js'; + +// -------------------------------------------------------- +// Helper Functions +// -------------------------------------------------------- + +/** + * Parse vCard text into structured object + * @param {string} vCardText - Raw vCard data + * @returns {Object} Parsed vCard object + */ +function parseVCard(vCardText) { + const lines = vCardText.split(/\r?\n/).filter(line => line.trim()); + const vcard = { + uid: null, + name: null, + phones: [], + emails: [], + addresses: [], + organization: null, + jobTitle: null, + website: null, + birthday: null, + photo: null, + nickname: null, + notes: null, + categories: null, + }; + + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + + // Handle line folding (continuation lines start with space or tab) + while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) { + line += lines[i + 1].substring(1); + i++; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const fullKey = line.substring(0, colonIndex); + const value = line.substring(colonIndex + 1).trim(); + + // Parse property and parameters + const [prop, ...params] = fullKey.split(';'); + const property = prop.toUpperCase(); + + switch (property) { + case 'UID': + vcard.uid = value; + break; + + case 'FN': + if (!vcard.name) vcard.name = value; + break; + + case 'N': + // N is fallback if FN is not present + // Format: Family;Given;Middle;Prefix;Suffix + if (!vcard.name) { + const parts = value.split(';').filter(p => p); + vcard.name = parts.join(' ').trim(); + } + break; + + case 'TEL': + const phoneType = extractType(params) || 'other'; + vcard.phones.push({ label: phoneType, value: value }); + break; + + case 'EMAIL': + const emailType = extractType(params) || 'other'; + vcard.emails.push({ label: emailType, value: value }); + break; + + case 'ADR': + // Format: POBox;Extended;Street;City;State;Postal;Country + const adrParts = value.split(';'); + const adrType = extractType(params) || 'other'; + vcard.addresses.push({ + label: adrType, + street: adrParts[2] || null, + city: adrParts[3] || null, + state: adrParts[4] || null, + postalCode: adrParts[5] || null, + country: adrParts[6] || null, + }); + break; + + case 'ORG': + vcard.organization = value; + break; + + case 'TITLE': + vcard.jobTitle = value; + break; + + case 'URL': + // Take first URL if multiple exist + if (!vcard.website) vcard.website = value; + break; + + case 'BDAY': + // Parse birthday to ISO format (YYYY-MM-DD) + vcard.birthday = parseBirthday(value); + break; + + case 'PHOTO': + // Handle base64 encoded photos + if (params.some(p => p.toUpperCase().includes('ENCODING=BASE64') || p.toUpperCase().includes('ENCODING=B'))) { + // Photo might span multiple lines in old vCard format + vcard.photo = value; + } + break; + + case 'NICKNAME': + vcard.nickname = value; + break; + + case 'NOTE': + vcard.notes = value; + break; + + case 'CATEGORIES': + vcard.categories = value; + break; + } + } + + return vcard; +} + +/** + * Extract TYPE parameter from vCard property parameters + * @param {Array} params - Property parameters + * @returns {string|null} Type value + */ +function extractType(params) { + // Priority order: more specific types first + const typeHierarchy = ['CELL', 'MOBILE', 'HOME', 'WORK', 'FAX', 'OTHER', 'VOICE']; + + let foundType = null; + + for (const param of params) { + const upper = param.toUpperCase(); + if (upper.startsWith('TYPE=')) { + return param.substring(5).toLowerCase(); + } + // Some vCards use TYPE without = + if (typeHierarchy.includes(upper)) { + // Keep the most specific type (earlier in hierarchy) + const currentIndex = typeHierarchy.indexOf(upper); + const foundIndex = foundType ? typeHierarchy.indexOf(foundType.toUpperCase()) : -1; + + if (foundIndex === -1 || currentIndex < foundIndex) { + foundType = upper.toLowerCase(); + } + } + } + + return foundType; +} + +/** + * Parse birthday from various vCard formats to ISO date + * @param {string} value - Birthday value from vCard + * @returns {string|null} ISO date (YYYY-MM-DD) or null + */ +function parseBirthday(value) { + if (!value) return null; + + // Remove any non-numeric characters except hyphens + const cleaned = value.replace(/[^\d-]/g, ''); + + // Try ISO format (YYYY-MM-DD) + if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) { + return cleaned; + } + + // Try compact format (YYYYMMDD) + if (/^\d{8}$/.test(cleaned)) { + return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 6)}-${cleaned.slice(6, 8)}`; + } + + // Try year only + if (/^\d{4}$/.test(cleaned)) { + return `${cleaned}-01-01`; + } + + return null; +} + +// -------------------------------------------------------- +// Account Management +// -------------------------------------------------------- + +/** + * Test CardDAV connection + * @param {string} cardavUrl - CardDAV server URL + * @param {string} username - Username + * @param {string} password - Password + * @returns {Promise} { ok: true, addressbooks: [...] } + */ +async function testConnection(cardavUrl, username, password) { + try { + const { createDAVClient } = await import('tsdav'); + const client = await createDAVClient({ + serverUrl: cardavUrl, + credentials: { username, password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + + const addressbooks = await client.fetchAddressBooks(); + if (!addressbooks.length) { + throw new Error('Connected, but no addressbooks found.'); + } + + return { ok: true, addressbooks }; + } catch (err) { + log.error('Connection test failed:', err.message); + throw new Error(`CardDAV connection failed: ${err.message}`); + } +} + +/** + * Add new CardDAV account + * @param {string} name - Account display name + * @param {string} cardavUrl - CardDAV server URL + * @param {string} username - Username + * @param {string} password - Password + * @returns {Promise} { accountId, addressbooks } + */ +async function addAccount(name, cardavUrl, username, password) { + try { + // Validate inputs + if (!name || !cardavUrl || !username || !password) { + throw new Error('All fields required: name, cardavUrl, username, password'); + } + + // Test connection first + const { addressbooks } = await testConnection(cardavUrl, username, password); + + // Check for duplicate + const existing = db.get().prepare( + 'SELECT id FROM carddav_accounts WHERE carddav_url = ? AND username = ?' + ).get(cardavUrl, username); + + if (existing) { + throw new Error('Account with this URL and username already exists.'); + } + + // Warn if DB_ENCRYPTION_KEY not set + if (!process.env.DB_ENCRYPTION_KEY) { + log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CardDAV credentials will be stored unencrypted.'); + } + + // Insert account + const result = db.get().prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password) + VALUES (?, ?, ?, ?) + `).run(name, cardavUrl, username, password); + + const accountId = result.lastInsertRowid; + + // Insert addressbook selections (all enabled by default) + const addressbookData = []; + for (const abook of addressbooks) { + const abookName = abook.displayName || 'Unnamed Addressbook'; + + db.get().prepare(` + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, 1) + `).run(accountId, abook.url, abookName); + + addressbookData.push({ url: abook.url, name: abookName, enabled: true }); + } + + log.info(`Added CardDAV account "${name}" with ${addressbooks.length} addressbooks.`); + + return { accountId, addressbooks: addressbookData }; + } catch (err) { + log.error('Failed to add account:', err.message); + throw err; + } +} + +/** + * Get all CardDAV accounts + * @returns {Array} Array of account objects (without passwords) + */ +function getAllAccounts() { + try { + const accounts = db.get().prepare(` + SELECT id, name, carddav_url, username, created_at, last_sync + FROM carddav_accounts + ORDER BY created_at DESC + `).all(); + + return accounts.map(acc => ({ + id: acc.id, + name: acc.name, + cardavUrl: acc.carddav_url, + username: acc.username, + createdAt: acc.created_at, + lastSync: acc.last_sync, + })); + } catch (err) { + log.error('Failed to get accounts:', err.message); + throw err; + } +} + +/** + * Delete CardDAV account + * @param {number} accountId - Account ID + * @returns {Object} { success: true } + */ +function deleteAccount(accountId) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + // CASCADE will delete carddav_addressbook_selection entries + // Contacts will have carddav_account_id SET NULL (see migration) + db.get().prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId); + + log.info(`Deleted CardDAV account ${accountId} ("${account.name}").`); + + return { success: true }; + } catch (err) { + log.error('Failed to delete account:', err.message); + throw err; + } +} + +// -------------------------------------------------------- +// Addressbook Discovery & Selection +// -------------------------------------------------------- + +/** + * Discover addressbooks for an account (refresh from server) + * @param {number} accountId - Account ID + * @returns {Promise>} Array of addressbook objects + */ +async function discoverAddressbooks(accountId) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + const { addressbooks } = await testConnection(account.carddav_url, account.username, account.password); + + // UPSERT into carddav_addressbook_selection + const result = []; + for (const abook of addressbooks) { + const abookName = abook.displayName || 'Unnamed Addressbook'; + + // Check if exists + const existing = db.get().prepare(` + SELECT id, enabled FROM carddav_addressbook_selection + WHERE account_id = ? AND addressbook_url = ? + `).get(accountId, abook.url); + + if (existing) { + // Update name only (preserve enabled state) + db.get().prepare(` + UPDATE carddav_addressbook_selection + SET addressbook_name = ? + WHERE id = ? + `).run(abookName, existing.id); + + result.push({ + id: existing.id, + url: abook.url, + name: abookName, + enabled: existing.enabled === 1 + }); + } else { + // Insert new (enabled by default) + const insertResult = db.get().prepare(` + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, 1) + `).run(accountId, abook.url, abookName); + + result.push({ + id: insertResult.lastInsertRowid, + url: abook.url, + name: abookName, + enabled: true + }); + } + } + + log.info(`Discovered ${addressbooks.length} addressbooks for account ${accountId}.`); + + return result; + } catch (err) { + log.error('Failed to discover addressbooks:', err.message); + throw err; + } +} + +/** + * Toggle addressbook enabled state + * @param {number} addressbookId - Addressbook selection ID + * @param {boolean} enabled - Enable or disable + * @returns {Object} { success: true } + */ +function toggleAddressbook(addressbookId, enabled) { + try { + const enabledValue = enabled ? 1 : 0; + + const result = db.get().prepare(` + UPDATE carddav_addressbook_selection + SET enabled = ? + WHERE id = ? + `).run(enabledValue, addressbookId); + + if (result.changes === 0) { + throw new Error(`Addressbook ${addressbookId} not found.`); + } + + log.info(`Addressbook ${addressbookId} ${enabled ? 'enabled' : 'disabled'}.`); + + return { success: true }; + } catch (err) { + log.error('Failed to toggle addressbook:', err.message); + throw err; + } +} + +// -------------------------------------------------------- +// Contact Sync +// -------------------------------------------------------- + +/** + * Sync all enabled addressbooks for an account + * @param {number} accountId - Account ID + * @returns {Promise} { synced, errors } + */ +async function syncAccount(accountId) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + log.info(`Syncing CardDAV account ${accountId} ("${account.name}")...`); + + // Create tsdav client + const { createDAVClient } = await import('tsdav'); + const client = await createDAVClient({ + serverUrl: account.carddav_url, + credentials: { username: account.username, password: account.password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + + // Get enabled addressbooks for this account + const enabledAddressbooks = db.get().prepare(` + SELECT id, addressbook_url, addressbook_name + FROM carddav_addressbook_selection + WHERE account_id = ? AND enabled = 1 + `).all(accountId); + + if (enabledAddressbooks.length === 0) { + log.info(`Account ${accountId}: no enabled addressbooks, skipping.`); + return { synced: 0, errors: 0 }; + } + + let totalSynced = 0; + let totalErrors = 0; + + // Fetch all addressbooks from server + const serverAddressbooks = await client.fetchAddressBooks(); + + for (const selAbook of enabledAddressbooks) { + // Find matching addressbook from server + const serverAbook = serverAddressbooks.find(sa => sa.url === selAbook.addressbook_url); + + if (!serverAbook) { + log.warn(`Addressbook ${selAbook.addressbook_url} not found on server, disabling.`); + db.get().prepare(` + UPDATE carddav_addressbook_selection SET enabled = 0 + WHERE id = ? + `).run(selAbook.id); + continue; + } + + // Sync this addressbook + const { synced, errors } = await syncAddressbook(accountId, selAbook.addressbook_url, client, serverAbook); + totalSynced += synced; + totalErrors += errors; + } + + // Update last_sync for account + db.get().prepare(` + UPDATE carddav_accounts SET last_sync = ? WHERE id = ? + `).run(new Date().toISOString(), accountId); + + log.info(`Account ${accountId} sync complete: ${totalSynced} contacts synced, ${totalErrors} errors.`); + + return { synced: totalSynced, errors: totalErrors }; + } catch (err) { + log.error(`Sync failed for account ${accountId}:`, err.message); + throw err; + } +} + +/** + * Sync a specific addressbook + * @param {number} accountId - Account ID + * @param {string} addressbookUrl - Addressbook URL + * @param {Object} client - tsdav client instance (optional, will create if not provided) + * @param {Object} serverAddressbook - Server addressbook object (optional) + * @returns {Promise} { synced, errors } + */ +async function syncAddressbook(accountId, addressbookUrl, client = null, serverAddressbook = null) { + try { + const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + // Create client if not provided + if (!client) { + const { createDAVClient } = await import('tsdav'); + client = await createDAVClient({ + serverUrl: account.carddav_url, + credentials: { username: account.username, password: account.password }, + authMethod: 'Basic', + defaultAccountType: 'carddav', + }); + } + + // Find addressbook if not provided + if (!serverAddressbook) { + const addressbooks = await client.fetchAddressBooks(); + serverAddressbook = addressbooks.find(ab => ab.url === addressbookUrl); + + if (!serverAddressbook) { + throw new Error(`Addressbook ${addressbookUrl} not found on server.`); + } + } + + // Fetch vCards from addressbook + let vcardObjects; + try { + vcardObjects = await client.fetchVCards({ addressBook: serverAddressbook }); + } catch (err) { + log.error(`Failed to fetch vCards from ${addressbookUrl}:`, err.message); + return { synced: 0, errors: 1 }; + } + + let synced = 0; + let errors = 0; + + // Parse and merge each vCard + for (const vcardObj of vcardObjects) { + try { + const vCardText = vcardObj.data || ''; + if (!vCardText.trim()) continue; + + await parseAndMergeContact(vCardText, accountId, addressbookUrl); + synced++; + } catch (err) { + log.error(`Failed to parse/merge vCard:`, err.message); + errors++; + } + } + + log.info(`Addressbook ${addressbookUrl}: ${synced} contacts synced, ${errors} errors.`); + + return { synced, errors }; + } catch (err) { + log.error(`Failed to sync addressbook ${addressbookUrl}:`, err.message); + throw err; + } +} + +/** + * Parse vCard and merge with existing contact using Smart Merge Logic + * @param {string} vCardText - Raw vCard data + * @param {number} accountId - Account ID + * @param {string} addressbookUrl - Addressbook URL + * @returns {Promise} Contact ID + */ +async function parseAndMergeContact(vCardText, accountId, addressbookUrl) { + try { + const vcard = parseVCard(vCardText); + + if (!vcard.uid) { + throw new Error('vCard missing UID, skipping.'); + } + + if (!vcard.name) { + throw new Error('vCard missing name (FN/N), skipping.'); + } + + // Smart Merge Logic (see design doc) + + // Step 1: Check for existing contact by cardav_uid + let contact = db.get().prepare(` + SELECT * FROM contacts + WHERE carddav_account_id = ? AND carddav_addressbook_url = ? AND carddav_uid = ? + `).get(accountId, addressbookUrl, vcard.uid); + + if (contact) { + // Update existing contact (only fill NULL fields to preserve manual changes) + updateContact(contact.id, vcard, false); + updateContactMultiValues(contact.id, vcard); + return contact.id; + } + + // Step 2: Check for existing contact by email or phone match + contact = findContactByEmailOrPhone(vcard.emails, vcard.phones); + + if (contact) { + // Update existing contact and establish CardDAV link + updateContact(contact.id, vcard, true); + + // Set CardDAV link + db.get().prepare(` + UPDATE contacts + SET carddav_account_id = ?, carddav_uid = ?, carddav_addressbook_url = ? + WHERE id = ? + `).run(accountId, vcard.uid, addressbookUrl, contact.id); + + updateContactMultiValues(contact.id, vcard); + return contact.id; + } + + // Step 3: No match - insert new contact + const result = db.get().prepare(` + INSERT INTO contacts ( + name, category, organization, job_title, birthday, website, + photo, nickname, notes, + carddav_account_id, carddav_uid, carddav_addressbook_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + vcard.name, + vcard.categories || 'Sonstiges', + vcard.organization, + vcard.jobTitle, + vcard.birthday, + vcard.website, + vcard.photo, + vcard.nickname, + vcard.notes, + accountId, + vcard.uid, + addressbookUrl + ); + + const contactId = result.lastInsertRowid; + + // Insert multi-value fields + insertContactMultiValues(contactId, vcard); + + return contactId; + } catch (err) { + log.error('Failed to parse and merge contact:', err.message); + throw err; + } +} + +/** + * Find existing contact by email or phone match + * @param {Array} emails - Array of email objects + * @param {Array} phones - Array of phone objects + * @returns {Object|null} Contact object or null + */ +function findContactByEmailOrPhone(emails, phones) { + // Try email match first + for (const email of emails) { + const contact = db.get().prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_emails ce ON c.id = ce.contact_id + WHERE c.email = ? OR ce.value = ? + LIMIT 1 + `).get(email.value, email.value); + + if (contact) return contact; + } + + // Try phone match + for (const phone of phones) { + const contact = db.get().prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_phones cp ON c.id = cp.contact_id + WHERE c.phone = ? OR cp.value = ? + LIMIT 1 + `).get(phone.value, phone.value); + + if (contact) return contact; + } + + return null; +} + +/** + * Update existing contact with vCard data (only NULL fields) + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + * @param {boolean} fillAll - If true, update all fields; if false, only update NULL fields + */ +function updateContact(contactId, vcard, fillAll = false) { + const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + if (!contact) return; + + const updates = []; + const values = []; + + // Helper to conditionally update field + const maybeUpdate = (field, dbColumn, vcardValue) => { + if (vcardValue !== null && vcardValue !== undefined) { + if (fillAll || contact[dbColumn] === null) { + updates.push(`${dbColumn} = ?`); + values.push(vcardValue); + } + } + }; + + maybeUpdate('name', 'name', vcard.name); + maybeUpdate('organization', 'organization', vcard.organization); + maybeUpdate('jobTitle', 'job_title', vcard.jobTitle); + maybeUpdate('birthday', 'birthday', vcard.birthday); + maybeUpdate('website', 'website', vcard.website); + maybeUpdate('photo', 'photo', vcard.photo); + maybeUpdate('nickname', 'nickname', vcard.nickname); + maybeUpdate('notes', 'notes', vcard.notes); + maybeUpdate('categories', 'category', vcard.categories); + + if (updates.length === 0) return; + + values.push(contactId); + + db.get().prepare(` + UPDATE contacts SET ${updates.join(', ')} WHERE id = ? + `).run(...values); +} + +/** + * Update contact multi-value fields (phones, emails, addresses) + * Preserves primary entries, replaces non-primary entries + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + */ +function updateContactMultiValues(contactId, vcard) { + // Delete non-primary entries + db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0').run(contactId); + db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ? AND is_primary = 0').run(contactId); + db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ? AND is_primary = 0').run(contactId); + + // Insert new entries from vCard + insertContactMultiValues(contactId, vcard); +} + +/** + * Insert contact multi-value fields (phones, emails, addresses) + * @param {number} contactId - Contact ID + * @param {Object} vcard - Parsed vCard object + */ +function insertContactMultiValues(contactId, vcard) { + // Check if primary entries exist + const hasPrimaryPhone = db.get().prepare( + 'SELECT COUNT(*) as count FROM contact_phones WHERE contact_id = ? AND is_primary = 1' + ).get(contactId).count > 0; + + const hasPrimaryEmail = db.get().prepare( + 'SELECT COUNT(*) as count FROM contact_emails WHERE contact_id = ? AND is_primary = 1' + ).get(contactId).count > 0; + + const hasPrimaryAddress = db.get().prepare( + 'SELECT COUNT(*) as count FROM contact_addresses WHERE contact_id = ? AND is_primary = 1' + ).get(contactId).count > 0; + + // Insert phones + for (let i = 0; i < vcard.phones.length; i++) { + const phone = vcard.phones[i]; + const isPrimary = (!hasPrimaryPhone && i === 0) ? 1 : 0; + + db.get().prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, phone.label, phone.value, isPrimary); + } + + // Insert emails + for (let i = 0; i < vcard.emails.length; i++) { + const email = vcard.emails[i]; + const isPrimary = (!hasPrimaryEmail && i === 0) ? 1 : 0; + + db.get().prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, email.label, email.value, isPrimary); + } + + // Insert addresses + for (let i = 0; i < vcard.addresses.length; i++) { + const addr = vcard.addresses[i]; + const isPrimary = (!hasPrimaryAddress && i === 0) ? 1 : 0; + + db.get().prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(contactId, addr.label, addr.street, addr.city, addr.state, addr.postalCode, addr.country, isPrimary); + } +} + +// -------------------------------------------------------- +// Exports +// -------------------------------------------------------- + +export { + // Account Management + addAccount, + getAllAccounts, + deleteAccount, + testConnection, + + // Addressbook Discovery + discoverAddressbooks, + toggleAddressbook, + + // Contact Sync + syncAccount, + syncAddressbook, + parseAndMergeContact, + + // Helpers (exported for testing) + parseVCard, +}; diff --git a/test-carddav.js b/test-carddav.js index 53dc6ad..d0ef10c 100644 --- a/test-carddav.js +++ b/test-carddav.js @@ -449,3 +449,521 @@ describe('CardDAV Contacts Schema (Migration 30)', () => { assert.ok(differentAccount, 'Same UID in different account should be allowed'); }); }); + +// ======================================== +// CardDAV Sync Service Tests +// ======================================== + +describe('CardDAV Sync Service', () => { + let testDb; + let parseVCard; + + before(async () => { + // Create in-memory test database + testDb = new Database(':memory:'); + testDb.pragma('foreign_keys = ON'); + + // Create minimal schema + testDb.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL + ); + + CREATE TABLE contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'Sonstiges', + phone TEXT, + email TEXT, + address TEXT, + notes TEXT, + family_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + INSERT INTO users (username) VALUES ('testuser'); + `); + + // Apply Migration 30 + const migration30 = MIGRATIONS.find(m => m.version === 30); + if (!migration30) { + throw new Error('Migration 30 not found'); + } + testDb.exec(migration30.up); + + // Import parseVCard helper for testing + const cardavSync = await import('./server/services/cardav-sync.js'); + parseVCard = cardavSync.parseVCard; + }); + + // ======================================== + // vCard Parsing Tests + // ======================================== + + describe('parseVCard', () => { + it('should parse basic vCard with FN and UID', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.uid, 'urn:uuid:12345'); + assert.strictEqual(result.name, 'John Doe'); + }); + + it('should parse N as fallback when FN missing', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +N:Doe;John;Middle;Mr.;Jr. +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.uid, 'urn:uuid:12345'); + assert.ok(result.name.includes('Doe')); + assert.ok(result.name.includes('John')); + }); + + it('should parse TEL fields with types', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +TEL;TYPE=CELL:+1234567890 +TEL;TYPE=WORK:+0987654321 +TEL;TYPE=HOME:+1111111111 +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.phones.length, 3); + + const cellPhone = result.phones.find(p => p.label === 'cell'); + assert.ok(cellPhone); + assert.strictEqual(cellPhone.value, '+1234567890'); + + const workPhone = result.phones.find(p => p.label === 'work'); + assert.ok(workPhone); + assert.strictEqual(workPhone.value, '+0987654321'); + }); + + it('should parse EMAIL fields with types', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +EMAIL;TYPE=HOME:john@home.com +EMAIL;TYPE=WORK:john@work.com +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.emails.length, 2); + + const homeEmail = result.emails.find(e => e.label === 'home'); + assert.ok(homeEmail); + assert.strictEqual(homeEmail.value, 'john@home.com'); + }); + + it('should parse ADR fields', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +ADR;TYPE=HOME:;;123 Main St;Springfield;IL;62701;USA +ADR;TYPE=WORK:;;456 Office Blvd;Metropolis;NY;10001;USA +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.addresses.length, 2); + + const homeAddr = result.addresses.find(a => a.label === 'home'); + assert.ok(homeAddr); + assert.strictEqual(homeAddr.street, '123 Main St'); + assert.strictEqual(homeAddr.city, 'Springfield'); + assert.strictEqual(homeAddr.state, 'IL'); + assert.strictEqual(homeAddr.postalCode, '62701'); + assert.strictEqual(homeAddr.country, 'USA'); + }); + + it('should parse organization and job title', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +ORG:Acme Corporation +TITLE:Senior Engineer +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.organization, 'Acme Corporation'); + assert.strictEqual(result.jobTitle, 'Senior Engineer'); + }); + + it('should parse birthday in various formats', () => { + const vCardText1 = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +BDAY:1990-05-15 +END:VCARD`; + + const result1 = parseVCard(vCardText1); + assert.strictEqual(result1.birthday, '1990-05-15'); + + const vCardText2 = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:Jane Doe +BDAY:19850312 +END:VCARD`; + + const result2 = parseVCard(vCardText2); + assert.strictEqual(result2.birthday, '1985-03-12'); + }); + + it('should parse URL, NICKNAME, NOTE, CATEGORIES', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +URL:https://example.com +NICKNAME:Johnny +NOTE:Important contact +CATEGORIES:Friends +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.website, 'https://example.com'); + assert.strictEqual(result.nickname, 'Johnny'); + assert.strictEqual(result.notes, 'Important contact'); + assert.strictEqual(result.categories, 'Friends'); + }); + + it('should handle line folding', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +NOTE:This is a very long note that spans + multiple lines and should be + concatenated properly +END:VCARD`; + + const result = parseVCard(vCardText); + assert.ok(result.notes.includes('very long note')); + assert.ok(result.notes.includes('multiple lines')); + assert.ok(result.notes.includes('concatenated properly')); + }); + + it('should handle vCards with minimal data', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:minimal +FN:Minimal Contact +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.uid, 'urn:uuid:minimal'); + assert.strictEqual(result.name, 'Minimal Contact'); + assert.strictEqual(result.phones.length, 0); + assert.strictEqual(result.emails.length, 0); + assert.strictEqual(result.addresses.length, 0); + }); + + it('should handle TEL without TYPE parameter', () => { + const vCardText = `BEGIN:VCARD +VERSION:3.0 +UID:urn:uuid:12345 +FN:John Doe +TEL;CELL:+1234567890 +TEL;VOICE;WORK:+0987654321 +END:VCARD`; + + const result = parseVCard(vCardText); + assert.strictEqual(result.phones.length, 2); + + // Should extract CELL and WORK from parameter names + const cellPhone = result.phones.find(p => p.label === 'cell'); + assert.ok(cellPhone); + + const workPhone = result.phones.find(p => p.label === 'work'); + assert.ok(workPhone); + }); + }); + + // ======================================== + // Database Integration Tests + // ======================================== + + describe('Account Management (DB)', () => { + it('should store and retrieve account correctly', () => { + testDb.prepare(` + INSERT INTO carddav_accounts (name, carddav_url, username, password) + VALUES (?, ?, ?, ?) + `).run('Test Account', 'https://carddav.example.com', 'user@example.com', 'password123'); + + const account = testDb.prepare('SELECT * FROM carddav_accounts WHERE name = ?').get('Test Account'); + assert.ok(account); + assert.strictEqual(account.name, 'Test Account'); + assert.strictEqual(account.carddav_url, 'https://carddav.example.com'); + assert.strictEqual(account.username, 'user@example.com'); + assert.strictEqual(account.password, 'password123'); + }); + + it('should create addressbook selections for account', () => { + const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Test Account').id; + + testDb.prepare(` + INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled) + VALUES (?, ?, ?, ?), (?, ?, ?, ?) + `).run( + accountId, 'https://carddav.example.com/addressbooks/personal', 'Personal', 1, + accountId, 'https://carddav.example.com/addressbooks/work', 'Work', 0 + ); + + const enabled = testDb.prepare(` + SELECT * FROM carddav_addressbook_selection + WHERE account_id = ? AND enabled = 1 + `).all(accountId); + + assert.strictEqual(enabled.length, 1); + assert.strictEqual(enabled[0].addressbook_name, 'Personal'); + + const all = testDb.prepare(` + SELECT * FROM carddav_addressbook_selection + WHERE account_id = ? + `).all(accountId); + + assert.strictEqual(all.length, 2); + }); + }); + + describe('Contact Merge Logic (DB)', () => { + it('should create new contact from vCard', () => { + const accountId = testDb.prepare('SELECT id FROM carddav_accounts WHERE name = ?').get('Test Account').id; + + // Simulate parsed vCard data + testDb.prepare(` + INSERT INTO contacts ( + name, category, organization, job_title, birthday, website, + nickname, notes, + carddav_account_id, carddav_uid, carddav_addressbook_url + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + 'Alice Smith', + 'Sonstiges', + 'Tech Corp', + 'Developer', + '1990-01-15', + 'https://alice.dev', + 'Ali', + 'Great developer', + accountId, + 'urn:uuid:alice-123', + 'https://carddav.example.com/addressbooks/personal' + ); + + const contact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith'); + assert.ok(contact); + assert.strictEqual(contact.organization, 'Tech Corp'); + assert.strictEqual(contact.job_title, 'Developer'); + assert.strictEqual(contact.birthday, '1990-01-15'); + assert.strictEqual(contact.carddav_uid, 'urn:uuid:alice-123'); + }); + + it('should add multiple phones to contact', () => { + const contact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith'); + + testDb.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?), (?, ?, ?, ?) + `).run( + contact.id, 'mobile', '+1234567890', 1, + contact.id, 'work', '+0987654321', 0 + ); + + const phones = testDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contact.id); + assert.strictEqual(phones.length, 2); + + const primary = phones.find(p => p.is_primary === 1); + assert.ok(primary); + assert.strictEqual(primary.value, '+1234567890'); + }); + + it('should add multiple emails to contact', () => { + const contact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith'); + + testDb.prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?), (?, ?, ?, ?) + `).run( + contact.id, 'home', 'alice@home.com', 1, + contact.id, 'work', 'alice@work.com', 0 + ); + + const emails = testDb.prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contact.id); + assert.strictEqual(emails.length, 2); + + const primary = emails.find(e => e.is_primary === 1); + assert.ok(primary); + assert.strictEqual(primary.value, 'alice@home.com'); + }); + + it('should add multiple addresses to contact', () => { + const contact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith'); + + testDb.prepare(` + INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + contact.id, 'home', '123 Main St', 'Springfield', 'IL', '62701', 'USA', 1 + ); + + const addresses = testDb.prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contact.id); + assert.strictEqual(addresses.length, 1); + assert.strictEqual(addresses[0].street, '123 Main St'); + assert.strictEqual(addresses[0].is_primary, 1); + }); + + it('should preserve primary entries when updating multi-values', () => { + const contact = testDb.prepare('SELECT * FROM contacts WHERE name = ?').get('Alice Smith'); + + // Mark first phone as primary (manually set) + testDb.prepare('UPDATE contact_phones SET is_primary = 1 WHERE contact_id = ? AND label = ?') + .run(contact.id, 'mobile'); + + // Delete non-primary phones (simulating sync update) + testDb.prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0') + .run(contact.id); + + // Add new phones from vCard + testDb.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contact.id, 'home', '+9999999999', 0); + + const phones = testDb.prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contact.id); + + // Should have primary mobile + new home phone + assert.strictEqual(phones.length, 2); + + const primaryPhone = phones.find(p => p.is_primary === 1); + assert.ok(primaryPhone); + assert.strictEqual(primaryPhone.label, 'mobile'); + }); + + it('should find contact by email match', () => { + // Create manual contact with email + testDb.prepare(` + INSERT INTO contacts (name, category, email) + VALUES (?, ?, ?) + `).run('Bob Jones', 'Sonstiges', 'bob@example.com'); + + const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Bob Jones').id; + + // Also add to contact_emails + testDb.prepare(` + INSERT INTO contact_emails (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'work', 'bob@work.com', 0); + + // Search by email (simulating merge logic) + const foundByOldEmail = testDb.prepare(` + SELECT c.* FROM contacts c + WHERE c.email = ? + `).get('bob@example.com'); + + assert.ok(foundByOldEmail); + assert.strictEqual(foundByOldEmail.name, 'Bob Jones'); + + const foundByNewEmail = testDb.prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_emails ce ON c.id = ce.contact_id + WHERE ce.value = ? + `).get('bob@work.com'); + + assert.ok(foundByNewEmail); + assert.strictEqual(foundByNewEmail.name, 'Bob Jones'); + }); + + it('should find contact by phone match', () => { + // Create manual contact with phone + testDb.prepare(` + INSERT INTO contacts (name, category, phone) + VALUES (?, ?, ?) + `).run('Carol White', 'Sonstiges', '+5555555555'); + + const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Carol White').id; + + // Also add to contact_phones + testDb.prepare(` + INSERT INTO contact_phones (contact_id, label, value, is_primary) + VALUES (?, ?, ?, ?) + `).run(contactId, 'mobile', '+6666666666', 0); + + // Search by phone (simulating merge logic) + const foundByOldPhone = testDb.prepare(` + SELECT c.* FROM contacts c + WHERE c.phone = ? + `).get('+5555555555'); + + assert.ok(foundByOldPhone); + assert.strictEqual(foundByOldPhone.name, 'Carol White'); + + const foundByNewPhone = testDb.prepare(` + SELECT c.* FROM contacts c + LEFT JOIN contact_phones cp ON c.id = cp.contact_id + WHERE cp.value = ? + `).get('+6666666666'); + + assert.ok(foundByNewPhone); + assert.strictEqual(foundByNewPhone.name, 'Carol White'); + }); + + it('should only update NULL fields when merging', () => { + // Create contact with some fields filled + testDb.prepare(` + INSERT INTO contacts (name, category, organization, job_title) + VALUES (?, ?, ?, ?) + `).run('Dave Brown', 'Sonstiges', 'Local Company', 'Manager'); + + const contactId = testDb.prepare('SELECT id FROM contacts WHERE name = ?').get('Dave Brown').id; + + // Simulate merge: only update NULL fields + const updates = []; + const values = []; + + const contact = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + + // birthday is NULL, should update + if (contact.birthday === null) { + updates.push('birthday = ?'); + values.push('1985-07-20'); + } + + // organization is NOT NULL, should not update + if (contact.organization === null) { + updates.push('organization = ?'); + values.push('New Company'); + } + + values.push(contactId); + + if (updates.length > 0) { + testDb.prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + const updated = testDb.prepare('SELECT * FROM contacts WHERE id = ?').get(contactId); + + // birthday should be updated + assert.strictEqual(updated.birthday, '1985-07-20'); + + // organization should remain unchanged + assert.strictEqual(updated.organization, 'Local Company'); + }); + }); +});