Merge branch 'feature/cardav-contacts'

# Conflicts:
#	package-lock.json
This commit is contained in:
Ulas Kalayci
2026-05-04 19:10:13 +02:00
11 changed files with 5518 additions and 33 deletions
+128 -1
View File
@@ -1074,6 +1074,112 @@ const MIGRATIONS = [
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
},
},
{
version: 30,
description: 'CardDAV multi-account contacts sync',
up: `
-- ========================================
-- CardDAV Accounts
-- ========================================
CREATE TABLE carddav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
carddav_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_sync TEXT,
UNIQUE(carddav_url, username)
);
-- ========================================
-- CardDAV Addressbook Selection
-- ========================================
CREATE TABLE carddav_addressbook_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
addressbook_url TEXT NOT NULL,
addressbook_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(account_id, addressbook_url),
FOREIGN KEY(account_id) REFERENCES carddav_accounts(id) ON DELETE CASCADE
);
CREATE INDEX idx_carddav_addressbook_account
ON carddav_addressbook_selection(account_id, enabled);
-- ========================================
-- Extend Contacts Table for CardDAV
-- ========================================
ALTER TABLE contacts ADD COLUMN organization TEXT;
ALTER TABLE contacts ADD COLUMN job_title TEXT;
ALTER TABLE contacts ADD COLUMN birthday TEXT;
ALTER TABLE contacts ADD COLUMN website TEXT;
ALTER TABLE contacts ADD COLUMN photo TEXT;
ALTER TABLE contacts ADD COLUMN nickname TEXT;
ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER
REFERENCES carddav_accounts(id) ON DELETE SET NULL;
ALTER TABLE contacts ADD COLUMN carddav_uid TEXT;
ALTER TABLE contacts ADD COLUMN carddav_addressbook_url TEXT;
CREATE INDEX idx_contacts_carddav_uid ON contacts(carddav_uid);
CREATE INDEX idx_contacts_email ON contacts(email);
-- UNIQUE constraint for CardDAV UIDs (prevents duplicates per account+addressbook)
CREATE UNIQUE INDEX idx_contacts_carddav_uid_unique
ON contacts(carddav_account_id, carddav_addressbook_url, carddav_uid)
WHERE carddav_uid IS NOT NULL;
-- ========================================
-- Contact Phones (Multiple per Contact)
-- ========================================
CREATE TABLE contact_phones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
value TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id);
CREATE INDEX idx_contact_phones_value ON contact_phones(value);
-- ========================================
-- Contact Emails (Multiple per Contact)
-- ========================================
CREATE TABLE contact_emails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
value TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id);
CREATE INDEX idx_contact_emails_value ON contact_emails(value);
-- ========================================
-- Contact Addresses (Multiple per Contact)
-- ========================================
CREATE TABLE contact_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
label TEXT,
street TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
country TEXT,
is_primary INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
`,
},
];
/**
@@ -1247,6 +1353,27 @@ function transaction(fn) {
return get().transaction(fn)();
}
let _originalDb = null;
/**
* ONLY FOR TESTING: Override the internal db instance
* @param {import('better-sqlite3').Database} testDb
*/
function _setTestDatabase(testDb) {
if (!_originalDb) _originalDb = db;
db = testDb;
}
/**
* ONLY FOR TESTING: Restore the original db instance
*/
function _resetTestDatabase() {
if (_originalDb) {
db = _originalDb;
_originalDb = null;
}
}
init(); // auto-initialise when module is first imported
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile };
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile, MIGRATIONS, _setTestDatabase, _resetTestDatabase };
+2
View File
@@ -26,6 +26,7 @@ import recipesRouter from './routes/recipes.js';
import calendarRouter from './routes/calendar.js';
import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
import cardavRouter from './routes/cardav.js';
import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js';
import documentsRouter from './routes/documents.js';
@@ -194,6 +195,7 @@ app.use('/api/v1/meals', mealsRouter);
app.use('/api/v1/recipes', recipesRouter);
app.use('/api/v1/calendar', calendarRouter);
app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts/cardav', cardavRouter);
app.use('/api/v1/contacts', contactsRouter);
app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter);
+17 -1
View File
@@ -156,8 +156,24 @@ function id(val, field) {
return { value: n, error: null };
}
/**
* Validiert einen Boolean-Wert.
* @param {any} val
* @param {string} field
* @returns {{ value: boolean|null, error: string|null }}
*/
function bool(val, field) {
if (val === undefined || val === null) {
return { value: null, error: `${field} is required.` };
}
if (typeof val !== 'boolean') {
return { value: null, error: `${field} must be a boolean.` };
}
return { value: val, error: null };
}
export {
str, oneOf, date, time, datetime, month, num, color, rrule, id, collectErrors,
str, oneOf, date, time, datetime, month, num, color, rrule, id, bool, collectErrors,
MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE,
DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE,
};
+25 -2
View File
@@ -457,11 +457,34 @@ function buildPaths() {
},
'/api/v1/contacts': {
get: op({ summary: 'List contacts', tag: 'Contacts' }),
post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
'/api/v1/contacts/cardav/accounts': {
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}': {
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/accounts/{id}/test': {
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/addressbooks/{id}': {
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}/sync': {
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}': {
put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
+205
View File
@@ -0,0 +1,205 @@
/**
* Modul: CardDAV Management
* Zweck: REST-API-Routen für CardDAV Account Management, Addressbook Discovery, Sync
* Abhängigkeiten: express, server/db.js, server/services/cardav-sync.js
*/
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import * as CardDAVSync from '../services/cardav-sync.js';
import { str, bool, collectErrors, MAX_TITLE } from '../middleware/validate.js';
const log = createLogger('CardDAV');
const MAX_URL = 500;
const router = express.Router();
/**
* GET /api/v1/contacts/cardav/accounts
* Liste aller CardDAV Accounts.
* Response: { data: Account[] }
*/
router.get('/accounts', async (req, res) => {
try {
const accounts = await CardDAVSync.getAllAccounts();
res.json({ data: accounts });
} catch (err) {
log.error('Error fetching accounts:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts
* Neuen CardDAV Account erstellen und Addressbooks discovern.
* Body: { name, cardavUrl, username, password }
* Response: { data: { account, addressbooks } }
*/
router.post('/accounts', async (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL });
const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE });
const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE });
const errors = collectErrors([vName, vUrl, vUsername, vPassword]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = await CardDAVSync.addAccount(
vName.value,
vUrl.value,
vUsername.value,
vPassword.value
);
res.status(201).json({ data: result });
} catch (err) {
log.error('Error adding CardDAV account:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/contacts/cardav/accounts/:id
* CardDAV Account löschen (CASCADE löscht addressbooks + contacts).
* Response: { data: { deleted: true } }
*/
router.delete('/accounts/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
await CardDAVSync.deleteAccount(id);
res.json({ data: { deleted: true } });
} catch (err) {
log.error('Error deleting CardDAV account:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/test
* Connection testen (ohne Account zu speichern).
* Response: { data: { ok, addressbooks } }
*/
router.post('/accounts/:id/test', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
const result = await CardDAVSync.testConnection(
account.carddav_url,
account.username,
account.password
);
res.json({ data: result });
} catch (err) {
log.error('Error testing CardDAV connection:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/cardav/accounts/:id/addressbooks
* Addressbooks für Account auflisten.
* Response: { data: Addressbook[] }
*/
router.get('/accounts/:id/addressbooks', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const addressbooks = db.get().prepare(`
SELECT id, addressbook_url as url, addressbook_name as name, enabled
FROM carddav_addressbook_selection
WHERE account_id = ?
ORDER BY addressbook_name
`).all(id);
res.json({ data: addressbooks });
} catch (err) {
log.error('Error fetching addressbooks:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
* Addressbooks neu discovern (PROPFIND).
* Response: { data: Addressbook[] }
*/
router.post('/accounts/:id/addressbooks/refresh', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
await CardDAVSync.discoverAddressbooks(id);
const addressbooks = db.get().prepare(`
SELECT id, addressbook_url as url, addressbook_name as name, enabled
FROM carddav_addressbook_selection
WHERE account_id = ?
ORDER BY addressbook_name
`).all(id);
res.json({ data: addressbooks });
} catch (err) {
log.error('Error refreshing addressbooks:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* PUT /api/v1/contacts/cardav/addressbooks/:id
* Toggle Addressbook enabled/disabled.
* Body: { enabled: boolean }
* Response: { data: { updated: true, enabled: boolean } }
*/
router.put('/addressbooks/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const vEnabled = bool(req.body.enabled, 'enabled');
const errors = collectErrors([vEnabled]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
CardDAVSync.toggleAddressbook(id, vEnabled.value);
res.json({ data: { updated: true, enabled: vEnabled.value } });
} catch (err) {
log.error('Error toggling addressbook:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/contacts/cardav/accounts/:id/sync
* Sync all enabled addressbooks for account.
* Response: { data: { synced: boolean, contactsAdded: number, contactsUpdated: number } }
*/
router.post('/accounts/:id/sync', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
const result = await CardDAVSync.syncAccount(id);
res.json({ data: result });
} catch (err) {
log.error('Error syncing account:', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
export default router;
+354 -28
View File
@@ -16,6 +16,140 @@ const router = express.Router();
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
'Handwerker', 'Notfall', 'Sonstiges'];
/**
* Loads multi-value fields (phones, emails, addresses) for a contact.
* @param {number} contactId - Contact ID
* @returns {{ phones: Array, emails: Array, addresses: Array }}
*/
function loadMultiValueFields(contactId) {
const phones = db.get().prepare(`
SELECT id, label, value, is_primary FROM contact_phones
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(p => ({
id: p.id,
label: p.label,
value: p.value,
isPrimary: p.is_primary === 1
}));
const emails = db.get().prepare(`
SELECT id, label, value, is_primary FROM contact_emails
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(e => ({
id: e.id,
label: e.label,
value: e.value,
isPrimary: e.is_primary === 1
}));
const addresses = db.get().prepare(`
SELECT id, label, street, city, state, postal_code, country, is_primary
FROM contact_addresses
WHERE contact_id = ?
ORDER BY is_primary DESC, id ASC
`).all(contactId).map(a => ({
id: a.id,
label: a.label,
street: a.street,
city: a.city,
state: a.state,
postalCode: a.postal_code,
country: a.country,
isPrimary: a.is_primary === 1
}));
return { phones, emails, addresses };
}
/**
* Validates phones array for multi-value contact fields.
* @param {Array} phones - Array of { label, value, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validatePhones(phones) {
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
if (phones.length > 20) return { valid: false, error: 'Too many phone entries (max 20)' };
for (const p of phones) {
if (!p || typeof p !== 'object') return { valid: false, error: 'Phone entry must be an object' };
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
if (typeof p.label !== 'string' || p.label.trim().length === 0 || p.label.length > 50) {
return { valid: false, error: 'Phone label invalid or too long' };
}
if (typeof p.value !== 'string' || p.value.trim().length === 0 || p.value.length > 50) {
return { valid: false, error: 'Phone value invalid or too long' };
}
if (p.isPrimary !== undefined && typeof p.isPrimary !== 'boolean') {
return { valid: false, error: 'Phone isPrimary must be boolean' };
}
}
return { valid: true };
}
/**
* Validates emails array for multi-value contact fields.
* @param {Array} emails - Array of { label, value, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validateEmails(emails) {
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
if (emails.length > 20) return { valid: false, error: 'Too many email entries (max 20)' };
for (const e of emails) {
if (!e || typeof e !== 'object') return { valid: false, error: 'Email entry must be an object' };
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
if (typeof e.label !== 'string' || e.label.trim().length === 0 || e.label.length > 50) {
return { valid: false, error: 'Email label invalid or too long' };
}
if (typeof e.value !== 'string' || e.value.trim().length === 0 || e.value.length > 255) {
return { valid: false, error: 'Email value invalid or too long' };
}
if (!/^.+@.+$/.test(e.value)) {
return { valid: false, error: 'Email value must be a valid email address' };
}
if (e.isPrimary !== undefined && typeof e.isPrimary !== 'boolean') {
return { valid: false, error: 'Email isPrimary must be boolean' };
}
}
return { valid: true };
}
/**
* Validates addresses array for multi-value contact fields.
* @param {Array} addresses - Array of { label, street?, city?, state?, postalCode?, country?, isPrimary? }
* @returns {{ valid: boolean, error?: string }}
*/
function validateAddresses(addresses) {
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
if (addresses.length > 20) return { valid: false, error: 'Too many address entries (max 20)' };
for (const a of addresses) {
if (!a || typeof a !== 'object') return { valid: false, error: 'Address entry must be an object' };
if (!a.label) return { valid: false, error: 'Address requires label' };
if (typeof a.label !== 'string' || a.label.trim().length === 0 || a.label.length > 50) {
return { valid: false, error: 'Address label invalid or too long' };
}
if (a.street !== undefined && (typeof a.street !== 'string' || a.street.length > 255)) {
return { valid: false, error: 'Address street invalid or too long' };
}
if (a.city !== undefined && (typeof a.city !== 'string' || a.city.length > 255)) {
return { valid: false, error: 'Address city invalid or too long' };
}
if (a.state !== undefined && (typeof a.state !== 'string' || a.state.length > 255)) {
return { valid: false, error: 'Address state invalid or too long' };
}
if (a.postalCode !== undefined && (typeof a.postalCode !== 'string' || a.postalCode.length > 255)) {
return { valid: false, error: 'Address postalCode invalid or too long' };
}
if (a.country !== undefined && (typeof a.country !== 'string' || a.country.length > 255)) {
return { valid: false, error: 'Address country invalid or too long' };
}
if (a.isPrimary !== undefined && typeof a.isPrimary !== 'boolean') {
return { valid: false, error: 'Address isPrimary must be boolean' };
}
}
return { valid: true };
}
/**
* GET /api/v1/contacts
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
@@ -53,7 +187,7 @@ router.get('/', (req, res) => {
/**
* POST /api/v1/contacts
* Neuen Kontakt anlegen.
* Body: { name, category?, phone?, email?, address?, notes? }
* Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? }
* Response: { data: Contact }
*/
router.post('/', (req, res) => {
@@ -67,14 +201,95 @@ router.post('/', (req, res) => {
const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = db.get().prepare(`
INSERT INTO contacts (name, category, phone, email, address, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value,
vAddress.value, vNotes.value);
// Validate multi-value fields if provided
if (req.body.phones !== undefined) {
const phonesValidation = validatePhones(req.body.phones);
if (!phonesValidation.valid) {
return res.status(400).json({ error: phonesValidation.error, code: 400 });
}
}
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ data: contact });
if (req.body.emails !== undefined) {
const emailsValidation = validateEmails(req.body.emails);
if (!emailsValidation.valid) {
return res.status(400).json({ error: emailsValidation.error, code: 400 });
}
}
if (req.body.addresses !== undefined) {
const addressesValidation = validateAddresses(req.body.addresses);
if (!addressesValidation.valid) {
return res.status(400).json({ error: addressesValidation.error, code: 400 });
}
}
// Insert contact and multi-value fields in a transaction
const transaction = db.get().transaction(() => {
const result = db.get().prepare(`
INSERT INTO contacts (name, category, phone, email, address, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value,
vAddress.value, vNotes.value);
const contactId = result.lastInsertRowid;
// Insert phones
if (req.body.phones && Array.isArray(req.body.phones)) {
const insertPhone = db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const phone of req.body.phones) {
insertPhone.run(contactId, phone.label, phone.value, phone.isPrimary ? 1 : 0);
}
}
// Insert emails
if (req.body.emails && Array.isArray(req.body.emails)) {
const insertEmail = db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const email of req.body.emails) {
insertEmail.run(contactId, email.label, email.value, email.isPrimary ? 1 : 0);
}
}
// Insert addresses
if (req.body.addresses && Array.isArray(req.body.addresses)) {
const insertAddress = db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const address of req.body.addresses) {
insertAddress.run(
contactId,
address.label,
address.street || null,
address.city || null,
address.state || null,
address.postalCode || null,
address.country || null,
address.isPrimary ? 1 : 0
);
}
}
return contactId;
});
const contactId = transaction();
// Query the created contact with multi-value fields
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
const multiValueFields = loadMultiValueFields(contactId);
res.status(201).json({
data: {
...contact,
...multiValueFields
}
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
@@ -84,7 +299,7 @@ router.post('/', (req, res) => {
/**
* PUT /api/v1/contacts/:id
* Kontakt bearbeiten.
* Body: alle Felder optional
* Body: alle Felder optional, phones/emails/addresses mit Replacement-Semantik
* Response: { data: Contact }
*/
router.put('/:id', (req, res) => {
@@ -103,27 +318,111 @@ router.put('/:id', (req, res) => {
const errors = collectErrors(checks);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
db.get().prepare(`
UPDATE contacts
SET name = COALESCE(?, name),
category = COALESCE(?, category),
phone = ?,
email = ?,
address = ?,
notes = ?
WHERE id = ?
`).run(
req.body.name?.trim() ?? null,
req.body.category ?? null,
req.body.phone !== undefined ? (req.body.phone?.trim() || null) : contact.phone,
req.body.email !== undefined ? (req.body.email?.trim() || null) : contact.email,
req.body.address !== undefined ? (req.body.address?.trim() || null) : contact.address,
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : contact.notes,
id
);
// Validate multi-value fields if provided
if (req.body.phones !== undefined) {
const phonesValidation = validatePhones(req.body.phones);
if (!phonesValidation.valid) {
return res.status(400).json({ error: phonesValidation.error, code: 400 });
}
}
if (req.body.emails !== undefined) {
const emailsValidation = validateEmails(req.body.emails);
if (!emailsValidation.valid) {
return res.status(400).json({ error: emailsValidation.error, code: 400 });
}
}
if (req.body.addresses !== undefined) {
const addressesValidation = validateAddresses(req.body.addresses);
if (!addressesValidation.valid) {
return res.status(400).json({ error: addressesValidation.error, code: 400 });
}
}
// Update contact and multi-value fields in a transaction
const transaction = db.get().transaction(() => {
// Update scalar fields
db.get().prepare(`
UPDATE contacts
SET name = COALESCE(?, name),
category = COALESCE(?, category),
phone = ?,
email = ?,
address = ?,
notes = ?
WHERE id = ?
`).run(
req.body.name?.trim() ?? null,
req.body.category ?? null,
req.body.phone !== undefined ? (req.body.phone?.trim() || null) : contact.phone,
req.body.email !== undefined ? (req.body.email?.trim() || null) : contact.email,
req.body.address !== undefined ? (req.body.address?.trim() || null) : contact.address,
req.body.notes !== undefined ? (req.body.notes?.trim() || null) : contact.notes,
id
);
// Replace phones (delete all, insert new)
if (req.body.phones !== undefined && Array.isArray(req.body.phones)) {
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
const insertPhone = db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const phone of req.body.phones) {
insertPhone.run(id, phone.label, phone.value, phone.isPrimary ? 1 : 0);
}
}
// Replace emails (delete all, insert new)
if (req.body.emails !== undefined && Array.isArray(req.body.emails)) {
db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ?').run(id);
const insertEmail = db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES (?, ?, ?, ?)
`);
for (const email of req.body.emails) {
insertEmail.run(id, email.label, email.value, email.isPrimary ? 1 : 0);
}
}
// Replace addresses (delete all, insert new)
if (req.body.addresses !== undefined && Array.isArray(req.body.addresses)) {
db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ?').run(id);
const insertAddress = db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const address of req.body.addresses) {
insertAddress.run(
id,
address.label,
address.street || null,
address.city || null,
address.state || null,
address.postalCode || null,
address.country || null,
address.isPrimary ? 1 : 0
);
}
}
});
transaction();
// Query the updated contact with multi-value fields
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
res.json({ data: updated });
const multiValueFields = loadMultiValueFields(id);
res.json({
data: {
...updated,
...multiValueFields
}
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
@@ -169,6 +468,32 @@ router.get('/meta', (_req, res) => {
}
});
/**
* GET /api/v1/contacts/:id
* Einzelnen Kontakt abrufen mit Multi-Value Fields (phones, emails, addresses).
* Response: { data: Contact }
*/
router.get('/:id', (req, res) => {
try {
const id = parseInt(req.params.id, 10);
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
// Load multi-value fields
const multiValueFields = loadMultiValueFields(id);
res.json({
data: {
...contact,
...multiValueFields
}
});
} catch (err) {
log.error('', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* GET /api/v1/contacts/:id/vcard
* Kontakt als vCard 3.0 (.vcf) exportieren.
@@ -208,3 +533,4 @@ router.get('/:id/vcard', (req, res) => {
});
export default router;
export { validatePhones, validateEmails, validateAddresses };
+916
View File
@@ -0,0 +1,916 @@
/**
* 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) {
// Use mock if set (for testing)
if (_testConnectionMock) {
return _testConnectionMock(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.`);
const account = {
id: accountId,
name,
cardavUrl,
username,
createdAt: new Date().toISOString(),
lastSync: null
};
return { account, 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) {
// Use mock if set (for testing)
if (_syncAccountMock) {
return _syncAccountMock(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) {
const transaction = db.get().transaction(() => {
// 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);
});
transaction();
}
/**
* 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 1 FROM contact_phones WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
const hasPrimaryEmail = db.get().prepare(
'SELECT 1 FROM contact_emails WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
const hasPrimaryAddress = db.get().prepare(
'SELECT 1 FROM contact_addresses WHERE contact_id = ? AND is_primary = 1'
).get(contactId);
// Batch insert phones
if (vcard.phones && vcard.phones.length > 0) {
const placeholders = vcard.phones.map(() => '(?, ?, ?, ?)').join(', ');
const values = vcard.phones.flatMap((phone, i) => [
contactId,
phone.label || null,
phone.value,
(!hasPrimaryPhone && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_phones (contact_id, label, value, is_primary)
VALUES ${placeholders}
`).run(...values);
}
// Batch insert emails
if (vcard.emails && vcard.emails.length > 0) {
const placeholders = vcard.emails.map(() => '(?, ?, ?, ?)').join(', ');
const values = vcard.emails.flatMap((email, i) => [
contactId,
email.label || null,
email.value,
(!hasPrimaryEmail && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_emails (contact_id, label, value, is_primary)
VALUES ${placeholders}
`).run(...values);
}
// Batch insert addresses
if (vcard.addresses && vcard.addresses.length > 0) {
const placeholders = vcard.addresses.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
const values = vcard.addresses.flatMap((addr, i) => [
contactId,
addr.label || null,
addr.street,
addr.city,
addr.state,
addr.postalCode,
addr.country,
(!hasPrimaryAddress && i === 0) ? 1 : 0
]);
db.get().prepare(`
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
VALUES ${placeholders}
`).run(...values);
}
}
// --------------------------------------------------------
// Exports
// --------------------------------------------------------
export {
// Account Management
addAccount,
getAllAccounts,
deleteAccount,
testConnection,
// Addressbook Discovery
discoverAddressbooks,
toggleAddressbook,
// Contact Sync
syncAccount,
syncAddressbook,
parseAndMergeContact,
// Helpers (exported for testing)
parseVCard,
_mockTestConnection,
_mockSyncAccount,
};
// --------------------------------------------------------
// Test Mocking Support
// --------------------------------------------------------
let _testConnectionMock = null;
let _syncAccountMock = null;
/**
* ONLY FOR TESTING: Mock testConnection for unit tests
* @param {Function|null} mockFn - Mock function or null to reset
*/
function _mockTestConnection(mockFn) {
_testConnectionMock = mockFn;
}
/**
* ONLY FOR TESTING: Mock syncAccount for unit tests
* @param {Function|null} mockFn - Mock function or null to reset
*/
function _mockSyncAccount(mockFn) {
_syncAccountMock = mockFn;
}