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 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 11:34:25 +02:00
parent 3f77fdb11d
commit 689b479b2d
2 changed files with 1367 additions and 0 deletions
+849
View File
@@ -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<string>} 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<Object>} { 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<Object>} { 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<Object>} 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<Object>>} 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<Object>} { 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<Object>} { 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<number>} 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<Object>} emails - Array of email objects
* @param {Array<Object>} 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,
};
+518
View File
@@ -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');
});
});
});