/** * Modul: Generic CalDAV Sync * Zweck: Multi-Account CalDAV synchronization with calendar selection * Abhängigkeiten: tsdav, server/db.js, server/services/ics-parser.js */ import { createLogger } from '../logger.js'; const log = createLogger('CalDAV'); import * as db from '../db.js'; // Reused functions from apple-calendar.js import { parseICS, formatICSDate, tzLocalToUTC, applyDuration } from './ics-parser.js'; // -------------------------------------------------------- // Helper Functions // -------------------------------------------------------- function normalizeCalColor(c) { if (!c) return null; if (/^#[0-9a-fA-F]{8}$/.test(c)) return c.slice(0, 7); // strip alpha if (/^#[0-9a-fA-F]{6}$/.test(c)) return c; return null; } function upsertExternalCalendar(source, externalId, name, color) { const row = db.get().prepare(` INSERT INTO external_calendars (source, external_id, name, color) VALUES (?, ?, ?, ?) ON CONFLICT(source, external_id) DO UPDATE SET name = excluded.name, color = excluded.color RETURNING id `).get(source, externalId, name, color); return row.id; } // -------------------------------------------------------- // Credentials Helpers // -------------------------------------------------------- function getAccountById(accountId) { return db.get().prepare('SELECT * FROM caldav_accounts WHERE id = ?').get(accountId); } function getAllAccounts() { return db.get().prepare('SELECT * FROM caldav_accounts').all(); } // -------------------------------------------------------- // Connection Testing // -------------------------------------------------------- async function testConnection(caldavUrl, username, password) { try { const { createDAVClient } = await import('tsdav'); const client = await createDAVClient({ serverUrl: caldavUrl, credentials: { username, password }, authMethod: 'Basic', defaultAccountType: 'caldav', }); const calendars = await client.fetchCalendars(); if (!calendars.length) { throw new Error('Connected, but no calendars found.'); } return { ok: true, calendars }; } catch (err) { log.error('Connection test failed:', err.message); throw new Error(`CalDAV connection failed: ${err.message}`); } } // -------------------------------------------------------- // Account Management // -------------------------------------------------------- async function addAccount(name, caldavUrl, username, password) { // Validate inputs if (!name || !caldavUrl || !username || !password) { throw new Error('All fields required: name, caldavUrl, username, password'); } // Test connection first const { calendars } = await testConnection(caldavUrl, username, password); // Check for duplicate const existing = db.get().prepare( 'SELECT id FROM caldav_accounts WHERE caldav_url = ? AND username = ?' ).get(caldavUrl, 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 - CalDAV credentials will be stored unencrypted.'); } // Insert account const result = db.get().prepare(` INSERT INTO caldav_accounts (name, caldav_url, username, password) VALUES (?, ?, ?, ?) `).run(name, caldavUrl, username, password); const accountId = result.lastInsertRowid; // Insert calendar selections (all enabled by default) const calendarData = []; for (const cal of calendars) { const calColor = normalizeCalColor(cal.calendarColor) || '#4A90E2'; const calName = cal.displayName || 'Unnamed Calendar'; db.get().prepare(` INSERT INTO caldav_calendar_selection (account_id, calendar_url, calendar_name, calendar_color, enabled) VALUES (?, ?, ?, ?, 1) `).run(accountId, cal.url, calName, calColor); calendarData.push({ url: cal.url, name: calName, color: calColor, enabled: true }); } log.info(`Added CalDAV account "${name}" with ${calendars.length} calendars.`); return { accountId, calendars: calendarData }; } function listAccounts() { const accounts = db.get().prepare(` SELECT id, name, caldav_url, username, created_at, last_sync FROM caldav_accounts ORDER BY created_at DESC `).all(); // Do NOT return password (security) return accounts.map(acc => ({ id: acc.id, name: acc.name, caldavUrl: acc.caldav_url, username: acc.username, createdAt: acc.created_at, lastSync: acc.last_sync, })); } async function updateAccount(accountId, { name, caldavUrl, username, password }) { const account = getAccountById(accountId); if (!account) { throw new Error(`Account ${accountId} not found.`); } // If credentials changed, test connection const credentialsChanged = (caldavUrl && caldavUrl !== account.caldav_url) || (username && username !== account.username) || (password && password !== account.password); if (credentialsChanged) { const testUrl = caldavUrl || account.caldav_url; const testUser = username || account.username; const testPwd = password || account.password; const { calendars } = await testConnection(testUrl, testUser, testPwd); // If credentials changed, refresh calendar list if (calendars) { // Delete old selections db.get().prepare('DELETE FROM caldav_calendar_selection WHERE account_id = ?').run(accountId); // Insert new selections for (const cal of calendars) { const calColor = normalizeCalColor(cal.calendarColor) || '#4A90E2'; const calName = cal.displayName || 'Unnamed Calendar'; db.get().prepare(` INSERT INTO caldav_calendar_selection (account_id, calendar_url, calendar_name, calendar_color, enabled) VALUES (?, ?, ?, ?, 1) `).run(accountId, cal.url, calName, calColor); } } } // Update account const updates = []; const values = []; if (name) { updates.push('name = ?'); values.push(name); } if (caldavUrl) { updates.push('caldav_url = ?'); values.push(caldavUrl); } if (username) { updates.push('username = ?'); values.push(username); } if (password) { updates.push('password = ?'); values.push(password); } if (updates.length === 0) { throw new Error('No fields to update.'); } values.push(accountId); db.get().prepare(` UPDATE caldav_accounts SET ${updates.join(', ')} WHERE id = ? `).run(...values); log.info(`Updated CalDAV account ${accountId}.`); return { success: true }; } function deleteAccount(accountId) { const account = getAccountById(accountId); if (!account) { throw new Error(`Account ${accountId} not found.`); } // CASCADE will delete caldav_calendar_selection entries db.get().prepare('DELETE FROM caldav_accounts WHERE id = ?').run(accountId); // Events with calendar_ref_id to deleted account remain (orphaned but visible) log.info(`Deleted CalDAV account ${accountId} ("${account.name}").`); return { success: true }; } // -------------------------------------------------------- // Exports // -------------------------------------------------------- export { addAccount, listAccounts, updateAccount, deleteAccount };