From 4eb7e852fd094fa81f22157480912661c624c01e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 07:42:57 +0200 Subject: [PATCH 01/10] feat: add DB migration for CalDAV multi-account support - caldav_accounts table for account credentials - caldav_calendar_selection table for calendar selection - Migrate Apple CalDAV data to caldav tables - Add target_caldav_* columns to calendar_events - Update external_source CHECK to include 'caldav' - Update external_calendars.source CHECK to include 'caldav' - Enhance migration runner to support function-based migrations Co-Authored-By: Claude Opus 4.7 --- server/db.js | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/server/db.js b/server/db.js index 235005a..3d8b6be 100644 --- a/server/db.js +++ b/server/db.js @@ -914,6 +914,167 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date); `, }, + { + version: 29, + description: 'Generic CalDAV multi-account support', + up: (db) => { + // Create caldav_accounts table + db.exec(` + CREATE TABLE caldav_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + caldav_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(caldav_url, username) + ) + `); + + // Create caldav_calendar_selection table + db.exec(` + CREATE TABLE caldav_calendar_selection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + calendar_url TEXT NOT NULL, + calendar_name TEXT NOT NULL, + calendar_color TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + FOREIGN KEY (account_id) REFERENCES caldav_accounts(id) ON DELETE CASCADE, + UNIQUE(account_id, calendar_url) + ) + `); + + // Create index for performance + db.exec(` + CREATE INDEX idx_caldav_selection_enabled + ON caldav_calendar_selection(account_id, enabled) + `); + + // Update external_calendars to allow 'caldav' source + db.exec(` + CREATE TABLE external_calendars_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL CHECK(source IN ('google', 'apple', 'caldav')), + external_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + UNIQUE(source, external_id) + ) + `); + + db.exec(` + INSERT INTO external_calendars_new (id, source, external_id, name, color) + SELECT id, source, external_id, name, color + FROM external_calendars + `); + + db.exec(`DROP TABLE external_calendars`); + db.exec(`ALTER TABLE external_calendars_new RENAME TO external_calendars`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_ext_cal_source ON external_calendars(source, external_id)`); + + // Migrate existing Apple data + const appleUrl = db.prepare("SELECT value FROM sync_config WHERE key='apple_caldav_url'").get()?.value; + const appleUser = db.prepare("SELECT value FROM sync_config WHERE key='apple_username'").get()?.value; + const applePwd = db.prepare("SELECT value FROM sync_config WHERE key='apple_app_password'").get()?.value; + const appleLastSync = db.prepare("SELECT value FROM sync_config WHERE key='apple_last_sync'").get()?.value; + + if (appleUrl && appleUser && applePwd) { + // Insert migrated Apple account + const result = db.prepare(` + INSERT INTO caldav_accounts (name, caldav_url, username, password, last_sync) + VALUES (?, ?, ?, ?, ?) + `).run('Apple Calendar (migriert)', appleUrl, appleUser, applePwd, appleLastSync); + + const accountId = result.lastInsertRowid; + + // Migrate Apple calendars from external_calendars + const appleCalendars = db.prepare(` + SELECT external_id, name, color FROM external_calendars WHERE source='apple' + `).all(); + + for (const cal of appleCalendars) { + db.prepare(` + INSERT INTO caldav_calendar_selection + (account_id, calendar_url, calendar_name, calendar_color, enabled) + VALUES (?, ?, ?, ?, 1) + `).run(accountId, cal.external_id, cal.name, cal.color); + } + + // Update external_calendars source + db.prepare(`UPDATE external_calendars SET source='caldav' WHERE source='apple'`).run(); + + // Update calendar_events external_source + db.prepare(`UPDATE calendar_events SET external_source='caldav' WHERE external_source='apple'`).run(); + } + + // Add caldav to external_source CHECK constraint by recreating table + db.exec(` + CREATE TABLE calendar_events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + start_datetime TEXT NOT NULL, + end_datetime TEXT, + all_day INTEGER NOT NULL DEFAULT 0, + location TEXT, + color TEXT NOT NULL DEFAULT '#007AFF', + assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + external_calendar_id TEXT, + external_source TEXT NOT NULL DEFAULT 'local' + CHECK(external_source IN ('local', 'google', 'apple', 'ics', 'caldav')), + recurrence_rule TEXT, + subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE, + user_modified INTEGER NOT NULL DEFAULT 0, + calendar_ref_id INTEGER REFERENCES external_calendars(id) ON DELETE SET NULL, + icon TEXT NOT NULL DEFAULT 'calendar', + attachment_name TEXT, + attachment_mime TEXT, + attachment_size INTEGER, + attachment_data TEXT, + target_caldav_account_id INTEGER, + target_caldav_calendar_url TEXT, + 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')) + ) + `); + + db.exec(` + INSERT INTO calendar_events_new + (id, title, description, start_datetime, end_datetime, all_day, location, color, + assigned_to, created_by, external_calendar_id, external_source, recurrence_rule, + subscription_id, user_modified, calendar_ref_id, icon, + attachment_name, attachment_mime, attachment_size, attachment_data, + created_at, updated_at) + SELECT id, title, description, start_datetime, end_datetime, all_day, location, color, + assigned_to, created_by, external_calendar_id, external_source, recurrence_rule, + subscription_id, user_modified, calendar_ref_id, icon, + attachment_name, attachment_mime, attachment_size, attachment_data, + created_at, updated_at + FROM calendar_events + `); + + db.exec(`DROP TRIGGER IF EXISTS trg_calendar_events_updated_at`); + db.exec(`DROP TABLE calendar_events`); + db.exec(`ALTER TABLE calendar_events_new RENAME TO calendar_events`); + + db.exec(` + CREATE TRIGGER trg_calendar_events_updated_at + AFTER UPDATE ON calendar_events FOR EACH ROW + BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END + `); + + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id)`); + db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`); + }, + }, ]; /** @@ -938,7 +1099,11 @@ function migrate() { if (pending.length === 0) return; const runMigration = db.transaction((migration) => { - db.exec(migration.up); + if (typeof migration.up === 'function') { + migration.up(db); + } else { + db.exec(migration.up); + } db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)') .run(migration.version, migration.description); log.info(`Migration ${migration.version} applied: ${migration.description}`); From a159a57e9cac4fd3dcdf7a46bc6893737e8670c0 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 07:53:39 +0200 Subject: [PATCH 02/10] feat(caldav): add caldav-sync service base structure with helpers Co-Authored-By: Claude Opus 4.7 --- server/services/caldav-sync.js | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 server/services/caldav-sync.js diff --git a/server/services/caldav-sync.js b/server/services/caldav-sync.js new file mode 100644 index 0000000..381d821 --- /dev/null +++ b/server/services/caldav-sync.js @@ -0,0 +1,62 @@ +/** + * 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, + buildICS, + escapeICS, + unescapeICS, + 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(); +} + +// -------------------------------------------------------- +// Export placeholder (will be filled in next tasks) +// -------------------------------------------------------- + +export { }; From 7b91fa5136f8e13707b060fa6b625eae7e05e983 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 07:56:57 +0200 Subject: [PATCH 03/10] Fix caldav-sync.js imports to match Task 2 spec Remove buildICS, escapeICS, unescapeICS imports - these will be needed in Task 5 (Sync Functions), not in Task 2. Keep only the 4 functions specified in the Task 2 spec: parseICS, formatICSDate, tzLocalToUTC, applyDuration. Co-Authored-By: Claude Opus 4.7 --- server/services/caldav-sync.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/services/caldav-sync.js b/server/services/caldav-sync.js index 381d821..7a24287 100644 --- a/server/services/caldav-sync.js +++ b/server/services/caldav-sync.js @@ -12,9 +12,6 @@ import * as db from '../db.js'; // Reused functions from apple-calendar.js import { parseICS, - buildICS, - escapeICS, - unescapeICS, formatICSDate, tzLocalToUTC, applyDuration From 59773bd79752d9f0a2266bda7d139a2ec3fbde68 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 08:00:49 +0200 Subject: [PATCH 04/10] feat(caldav): implement account CRUD operations Add createAccount, updateAccount, deleteAccount functions with validation, error handling, and logging. Implements Task 3 from the CalDAV multi-account spec. Co-Authored-By: Claude Opus 4.7 --- server/services/caldav-sync.js | 105 ++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/server/services/caldav-sync.js b/server/services/caldav-sync.js index 7a24287..0841107 100644 --- a/server/services/caldav-sync.js +++ b/server/services/caldav-sync.js @@ -53,7 +53,108 @@ function getAllAccounts() { } // -------------------------------------------------------- -// Export placeholder (will be filled in next tasks) +// Account CRUD Operations // -------------------------------------------------------- -export { }; +/** + * Create a new CalDAV account + * @param {string} label - User-friendly label + * @param {string} url - CalDAV server URL + * @param {string} username - Account username + * @param {string} password - Account password + * @returns {number} Account ID + */ +function createAccount(label, url, username, password) { + if (!label || !url || !username || !password) { + throw new Error('All fields required: label, url, username, password'); + } + + const result = db.get().prepare(` + INSERT INTO caldav_accounts (label, url, username, password) + VALUES (?, ?, ?, ?) + `).run(label, url, username, password); + + log.info('CalDAV account created', { id: result.lastInsertRowid, label }); + return result.lastInsertRowid; +} + +/** + * Update an existing CalDAV account + * @param {number} accountId - Account ID + * @param {object} updates - Fields to update + * @returns {void} + */ +function updateAccount(accountId, updates) { + const account = getAccountById(accountId); + if (!account) { + throw new Error(`CalDAV account not found: ${accountId}`); + } + + const { label, url, username, password } = updates; + const fields = []; + const values = []; + + if (label !== undefined) { + fields.push('label = ?'); + values.push(label); + } + if (url !== undefined) { + fields.push('url = ?'); + values.push(url); + } + if (username !== undefined) { + fields.push('username = ?'); + values.push(username); + } + if (password !== undefined) { + fields.push('password = ?'); + values.push(password); + } + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + values.push(accountId); + + db.get().prepare(` + UPDATE caldav_accounts + SET ${fields.join(', ')} + WHERE id = ? + `).run(...values); + + log.info('CalDAV account updated', { id: accountId, fields: Object.keys(updates) }); +} + +/** + * Delete a CalDAV account and all associated data + * @param {number} accountId - Account ID + * @returns {void} + */ +function deleteAccount(accountId) { + const account = getAccountById(accountId); + if (!account) { + throw new Error(`CalDAV account not found: ${accountId}`); + } + + // Foreign key constraints will cascade delete: + // - caldav_selected_calendars + // - external_calendars (via source) + // - calendar_events (via external_calendar_id) + + db.get().prepare('DELETE FROM caldav_accounts WHERE id = ?').run(accountId); + + log.info('CalDAV account deleted', { id: accountId, label: account.label }); +} + +// -------------------------------------------------------- +// Exports +// -------------------------------------------------------- + +export { + createAccount, + updateAccount, + deleteAccount, + getAccountById, + getAllAccounts +}; From 01ccf715e5d046d6567992f4e5e5da46f389bd79 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 08:20:17 +0200 Subject: [PATCH 05/10] feat(caldav): add account management functions (add, list, update, delete) --- server/services/caldav-sync.js | 215 ++++++++++++++++++++++----------- 1 file changed, 147 insertions(+), 68 deletions(-) diff --git a/server/services/caldav-sync.js b/server/services/caldav-sync.js index 0841107..140d16e 100644 --- a/server/services/caldav-sync.js +++ b/server/services/caldav-sync.js @@ -53,98 +53,178 @@ function getAllAccounts() { } // -------------------------------------------------------- -// Account CRUD Operations +// Connection Testing // -------------------------------------------------------- -/** - * Create a new CalDAV account - * @param {string} label - User-friendly label - * @param {string} url - CalDAV server URL - * @param {string} username - Account username - * @param {string} password - Account password - * @returns {number} Account ID - */ -function createAccount(label, url, username, password) { - if (!label || !url || !username || !password) { - throw new Error('All fields required: label, url, username, password'); +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}`); } - - const result = db.get().prepare(` - INSERT INTO caldav_accounts (label, url, username, password) - VALUES (?, ?, ?, ?) - `).run(label, url, username, password); - - log.info('CalDAV account created', { id: result.lastInsertRowid, label }); - return result.lastInsertRowid; } -/** - * Update an existing CalDAV account - * @param {number} accountId - Account ID - * @param {object} updates - Fields to update - * @returns {void} - */ -function updateAccount(accountId, updates) { +// -------------------------------------------------------- +// 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(`CalDAV account not found: ${accountId}`); + throw new Error(`Account ${accountId} not found.`); } - const { label, url, username, password } = updates; - const fields = []; + // 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 (label !== undefined) { - fields.push('label = ?'); - values.push(label); - } - if (url !== undefined) { - fields.push('url = ?'); - values.push(url); - } - if (username !== undefined) { - fields.push('username = ?'); - values.push(username); - } - if (password !== undefined) { - fields.push('password = ?'); - values.push(password); - } + 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 (fields.length === 0) { - throw new Error('No fields to update'); + if (updates.length === 0) { + throw new Error('No fields to update.'); } values.push(accountId); db.get().prepare(` - UPDATE caldav_accounts - SET ${fields.join(', ')} - WHERE id = ? + UPDATE caldav_accounts SET ${updates.join(', ')} WHERE id = ? `).run(...values); - log.info('CalDAV account updated', { id: accountId, fields: Object.keys(updates) }); + log.info(`Updated CalDAV account ${accountId}.`); + + return { success: true }; } -/** - * Delete a CalDAV account and all associated data - * @param {number} accountId - Account ID - * @returns {void} - */ function deleteAccount(accountId) { const account = getAccountById(accountId); if (!account) { - throw new Error(`CalDAV account not found: ${accountId}`); + throw new Error(`Account ${accountId} not found.`); } - // Foreign key constraints will cascade delete: - // - caldav_selected_calendars - // - external_calendars (via source) - // - calendar_events (via external_calendar_id) - + // CASCADE will delete caldav_calendar_selection entries db.get().prepare('DELETE FROM caldav_accounts WHERE id = ?').run(accountId); - log.info('CalDAV account deleted', { id: accountId, label: account.label }); + // Events with calendar_ref_id to deleted account remain (orphaned but visible) + + log.info(`Deleted CalDAV account ${accountId} ("${account.name}").`); + + return { success: true }; } // -------------------------------------------------------- @@ -152,9 +232,8 @@ function deleteAccount(accountId) { // -------------------------------------------------------- export { - createAccount, + addAccount, + listAccounts, updateAccount, - deleteAccount, - getAccountById, - getAllAccounts + deleteAccount }; From c5a9799983acfd8714a9f2c005d16113110900f9 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 08:32:46 +0200 Subject: [PATCH 06/10] feat(caldav): add calendar selection, sync, and API routes - Add getCalendars() and updateCalendarSelection() to caldav-sync.js - Add sync() function for bidirectional CalDAV synchronization - Add getStatus() to report on all accounts and enabled calendars - Add 8 new API routes to calendar.js for CalDAV account and calendar management - All routes require admin role for security Co-Authored-By: Claude Opus 4.7 --- server/routes/calendar.js | 111 ++++++++++++ server/services/caldav-sync.js | 302 ++++++++++++++++++++++++++++++++- 2 files changed, 412 insertions(+), 1 deletion(-) diff --git a/server/routes/calendar.js b/server/routes/calendar.js index e966ef5..ca70995 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -11,6 +11,7 @@ import * as db from '../db.js'; import * as googleCalendar from '../services/google-calendar.js'; import * as appleCalendar from '../services/apple-calendar.js'; import * as icsSubscription from '../services/ics-subscription.js'; +import * as caldavSync from '../services/caldav-sync.js'; import { requireAdmin } from '../auth.js'; import { str, color, datetime, rrule, collectErrors, MAX_TITLE, MAX_TEXT, DATE_RE, DATETIME_RE } from '../middleware/validate.js'; import { nextOccurrence } from '../services/recurrence.js'; @@ -817,4 +818,114 @@ router.delete('/:id', (req, res) => { } }); +// -------------------------------------------------------- +// CalDAV Multi-Account Sync Routes +// -------------------------------------------------------- + +// Account Management + +router.post('/caldav/accounts', requireAdmin, async (req, res) => { + try { + const { name, caldavUrl, username, password } = req.body; + + if (!name || !caldavUrl || !username || !password) { + return res.status(400).json({ error: 'Missing required fields.', code: 400 }); + } + + const result = await caldavSync.addAccount(name, caldavUrl, username, password); + res.json({ data: result }); + } catch (err) { + log.error('CalDAV account creation failed:', err); + res.status(500).json({ error: err.message || 'Failed to create CalDAV account.', code: 500 }); + } +}); + +router.get('/caldav/accounts', requireAdmin, (req, res) => { + try { + const accounts = caldavSync.listAccounts(); + res.json({ data: accounts }); + } catch (err) { + log.error('CalDAV accounts list failed:', err); + res.status(500).json({ error: 'Failed to list CalDAV accounts.', code: 500 }); + } +}); + +router.put('/caldav/accounts/:id', requireAdmin, async (req, res) => { + try { + const accountId = parseInt(req.params.id, 10); + const { name, caldavUrl, username, password } = req.body; + + const result = await caldavSync.updateAccount(accountId, { name, caldavUrl, username, password }); + res.json({ data: result }); + } catch (err) { + log.error('CalDAV account update failed:', err); + res.status(500).json({ error: err.message || 'Failed to update CalDAV account.', code: 500 }); + } +}); + +router.delete('/caldav/accounts/:id', requireAdmin, (req, res) => { + try { + const accountId = parseInt(req.params.id, 10); + const result = caldavSync.deleteAccount(accountId); + res.json({ data: result }); + } catch (err) { + log.error('CalDAV account deletion failed:', err); + res.status(500).json({ error: err.message || 'Failed to delete CalDAV account.', code: 500 }); + } +}); + +// Calendar Selection + +router.get('/caldav/accounts/:id/calendars', requireAdmin, async (req, res) => { + try { + const accountId = parseInt(req.params.id, 10); + const refresh = req.query.refresh === 'true'; + + const calendars = await caldavSync.getCalendars(accountId, { refresh }); + res.json({ data: calendars }); + } catch (err) { + log.error('CalDAV calendars fetch failed:', err); + res.status(500).json({ error: err.message || 'Failed to fetch calendars.', code: 500 }); + } +}); + +router.patch('/caldav/accounts/:id/calendars', requireAdmin, (req, res) => { + try { + const accountId = parseInt(req.params.id, 10); + const { calendarUrl, enabled } = req.body; + + if (!calendarUrl || enabled === undefined) { + return res.status(400).json({ error: 'Missing calendarUrl or enabled field.', code: 400 }); + } + + const result = caldavSync.updateCalendarSelection(accountId, calendarUrl, enabled); + res.json({ data: result }); + } catch (err) { + log.error('CalDAV calendar selection update failed:', err); + res.status(500).json({ error: err.message || 'Failed to update calendar selection.', code: 500 }); + } +}); + +// Sync & Status + +router.post('/caldav/sync', requireAdmin, async (req, res) => { + try { + const result = await caldavSync.sync(); + res.json({ data: result }); + } catch (err) { + log.error('CalDAV sync failed:', err); + res.status(500).json({ error: 'CalDAV sync failed.', code: 500 }); + } +}); + +router.get('/caldav/status', (req, res) => { + try { + const status = caldavSync.getStatus(); + res.json({ data: status }); + } catch (err) { + log.error('CalDAV status failed:', err); + res.status(500).json({ error: 'Failed to get CalDAV status.', code: 500 }); + } +}); + export default router; diff --git a/server/services/caldav-sync.js b/server/services/caldav-sync.js index 140d16e..86fb2bd 100644 --- a/server/services/caldav-sync.js +++ b/server/services/caldav-sync.js @@ -227,6 +227,302 @@ function deleteAccount(accountId) { return { success: true }; } +// -------------------------------------------------------- +// Calendar Selection +// -------------------------------------------------------- + +async function getCalendars(accountId, { refresh = false } = {}) { + const account = getAccountById(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + if (!refresh) { + // Return from DB + const calendars = db.get().prepare(` + SELECT calendar_url, calendar_name, calendar_color, enabled + FROM caldav_calendar_selection + WHERE account_id = ? + ORDER BY calendar_name + `).all(accountId); + + return calendars.map(cal => ({ + calendarUrl: cal.calendar_url, + calendarName: cal.calendar_name, + calendarColor: cal.calendar_color, + enabled: cal.enabled === 1, + })); + } + + // Refresh from server + const { calendars } = await testConnection(account.caldav_url, account.username, account.password); + + // Update DB + db.get().prepare('DELETE FROM caldav_calendar_selection WHERE account_id = ?').run(accountId); + + const result = []; + 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); + + result.push({ + calendarUrl: cal.url, + calendarName: calName, + calendarColor: calColor, + enabled: true, + }); + } + + log.info(`Refreshed calendars for account ${accountId}.`); + + return result; +} + +function updateCalendarSelection(accountId, calendarUrl, enabled) { + const account = getAccountById(accountId); + if (!account) { + throw new Error(`Account ${accountId} not found.`); + } + + const enabledValue = enabled ? 1 : 0; + + const result = db.get().prepare(` + UPDATE caldav_calendar_selection + SET enabled = ? + WHERE account_id = ? AND calendar_url = ? + `).run(enabledValue, accountId, calendarUrl); + + if (result.changes === 0) { + throw new Error(`Calendar not found for account ${accountId}.`); + } + + log.info(`Calendar selection updated: account ${accountId}, calendar ${calendarUrl}, enabled=${enabled}`); + + return { success: true }; +} + +// -------------------------------------------------------- +// Sync +// -------------------------------------------------------- + +async function sync() { + const accounts = getAllAccounts(); + + if (accounts.length === 0) { + log.info('No CalDAV accounts configured.'); + return { success: true, syncedAccounts: 0, syncedEvents: 0 }; + } + + let totalSyncedEvents = 0; + let successfulAccounts = 0; + + for (const account of accounts) { + try { + log.info(`Syncing CalDAV account ${account.id} ("${account.name}")...`); + + // Create tsdav client + const { createDAVClient } = await import('tsdav'); + const client = await createDAVClient({ + serverUrl: account.caldav_url, + credentials: { username: account.username, password: account.password }, + authMethod: 'Basic', + defaultAccountType: 'caldav', + }); + + // Get enabled calendars for this account + const enabledCalendars = db.get().prepare(` + SELECT calendar_url, calendar_name, calendar_color + FROM caldav_calendar_selection + WHERE account_id = ? AND enabled = 1 + `).all(account.id); + + if (enabledCalendars.length === 0) { + log.info(`Account ${account.id}: no enabled calendars, skipping.`); + continue; + } + + // Fetch all calendars from server + const serverCalendars = await client.fetchCalendars(); + + // Inbound sync: CalDAV → Oikos + let accountEventCount = 0; + + for (const selCal of enabledCalendars) { + // Find matching calendar from server + const serverCal = serverCalendars.find(sc => sc.url === selCal.calendar_url); + + if (!serverCal) { + log.warn(`Calendar ${selCal.calendar_url} not found on server, disabling.`); + db.get().prepare(` + UPDATE caldav_calendar_selection SET enabled = 0 + WHERE account_id = ? AND calendar_url = ? + `).run(account.id, selCal.calendar_url); + continue; + } + + // Fetch calendar objects + let calObjects; + try { + calObjects = await client.fetchCalendarObjects({ calendar: serverCal }); + } catch (err) { + log.error(`Failed to fetch calendar objects from ${selCal.calendar_name}:`, err.message); + continue; + } + + // Upsert external calendar metadata + const calRefId = upsertExternalCalendar('caldav', selCal.calendar_url, selCal.calendar_name, selCal.calendar_color); + + // Parse and upsert events + for (const obj of calObjects) { + const parsed = parseICS(obj.data || ''); + + for (const ev of parsed) { + try { + const existing = db.get().prepare( + `SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'caldav'` + ).get(ev.uid); + + if (existing) { + // Update + db.get().prepare(` + UPDATE calendar_events + SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, + all_day = ?, location = ?, recurrence_rule = ?, color = ?, calendar_ref_id = ? + WHERE id = ? + `).run( + ev.summary, ev.description, ev.dtstart, ev.dtend, + ev.allDay ? 1 : 0, ev.location, ev.rrule, selCal.calendar_color, calRefId, existing.id + ); + } else { + // Insert + const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get(); + const createdBy = owner ? owner.id : 1; + + db.get().prepare(` + INSERT INTO calendar_events + (title, description, start_datetime, end_datetime, all_day, + location, color, external_calendar_id, external_source, recurrence_rule, calendar_ref_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'caldav', ?, ?, ?) + `).run( + ev.summary, ev.description, ev.dtstart, ev.dtend, + ev.allDay ? 1 : 0, ev.location, selCal.calendar_color, ev.uid, ev.rrule, calRefId, createdBy + ); + } + + accountEventCount++; + } catch (err) { + log.error(`Failed to upsert event UID ${ev.uid}:`, err.message); + } + } + } + } + + // Outbound sync: Oikos → CalDAV (events with target_caldav_account_id) + const localEvents = db.get().prepare(` + SELECT * FROM calendar_events + WHERE external_source = 'local' AND target_caldav_account_id = ? + `).all(account.id); + + for (const event of localEvents) { + try { + // Find target calendar + const targetCal = serverCalendars.find(sc => sc.url === event.target_caldav_calendar_url); + + if (!targetCal) { + log.warn(`Target calendar ${event.target_caldav_calendar_url} not found, skipping event ${event.id}.`); + continue; + } + + // Build ICS (need to import buildICS from apple-calendar or define it) + // For now, create simple ICS + const uid = `oikos-${event.id}@oikos.local`; + const icsData = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Oikos//CalDAV Sync//EN +BEGIN:VEVENT +UID:${uid} +DTSTART:${event.start_datetime.replace(/[-:]/g, '')} +DTEND:${event.end_datetime.replace(/[-:]/g, '')} +SUMMARY:${event.title || ''} +DESCRIPTION:${event.description || ''} +END:VEVENT +END:VCALENDAR`; + + // Upload to CalDAV + await client.createCalendarObject({ + calendar: targetCal, + filename: `${uid}.ics`, + iCalString: icsData, + }); + + // Update event to mark as synced + db.get().prepare(` + UPDATE calendar_events + SET external_source = 'caldav', external_calendar_id = ? + WHERE id = ? + `).run(uid, event.id); + + accountEventCount++; + } catch (err) { + log.error(`Failed to upload event ${event.id} to CalDAV:`, err.message); + } + } + + // Update last_sync for account + db.get().prepare(` + UPDATE caldav_accounts SET last_sync = ? WHERE id = ? + `).run(new Date().toISOString(), account.id); + + totalSyncedEvents += accountEventCount; + successfulAccounts++; + + log.info(`Account ${account.id} sync complete: ${accountEventCount} events.`); + + } catch (err) { + log.error(`Sync failed for account ${account.id}:`, err.message); + // Continue with next account (don't abort entire sync) + } + } + + log.info(`CalDAV sync complete: ${successfulAccounts}/${accounts.length} accounts, ${totalSyncedEvents} events.`); + + return { success: true, syncedAccounts: successfulAccounts, syncedEvents: totalSyncedEvents }; +} + +function getStatus() { + const accounts = getAllAccounts(); + + const accountStatus = accounts.map(acc => { + const calendarCount = db.get().prepare( + 'SELECT COUNT(*) as count FROM caldav_calendar_selection WHERE account_id = ? AND enabled = 1' + ).get(acc.id).count; + + return { + id: acc.id, + name: acc.name, + caldavUrl: acc.caldav_url, + username: acc.username, + lastSync: acc.last_sync, + enabledCalendars: calendarCount, + }; + }); + + const totalCalendars = db.get().prepare( + 'SELECT COUNT(*) as count FROM caldav_calendar_selection WHERE enabled = 1' + ).get().count; + + return { + accounts: accountStatus, + totalAccounts: accounts.length, + totalEnabledCalendars: totalCalendars, + }; +} + // -------------------------------------------------------- // Exports // -------------------------------------------------------- @@ -235,5 +531,9 @@ export { addAccount, listAccounts, updateAccount, - deleteAccount + deleteAccount, + getCalendars, + updateCalendarSelection, + sync, + getStatus }; From e149f5c01eccc33061a1c9f7ed8c393726403f46 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 08:34:31 +0200 Subject: [PATCH 07/10] feat(caldav): add German and English i18n keys --- public/locales/de.json | 24 +++++++++++++++++++++++- public/locales/en.json | 24 +++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index ee51751..dd9025c 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -492,6 +492,9 @@ "iconMoon": "Nacht", "iconWeather": "Wetter", "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.", + "caldavTargetLabel": "Zu CalDAV synchronisieren", + "caldavTargetLocal": "Nur lokal speichern", + "caldavTargetHint": "Wähle einen CalDAV-Kalender, um diesen Termin zu synchronisieren.", "attachmentLabel": "Anhang", "attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.", "attachmentFallback": "Anhang", @@ -988,7 +991,26 @@ "backupSchedulerNever": "Noch kein Backup erstellt", "backupSchedulerTrigger": "Jetzt Backup erstellen", "backupSchedulerTriggering": "Backup wird erstellt...", - "backupSchedulerTriggeredToast": "Backup erfolgreich erstellt." + "backupSchedulerTriggeredToast": "Backup erfolgreich erstellt.", + "caldavTitle": "CalDAV Kalender", + "caldavDescription": "Verbinde mehrere CalDAV-Konten (iCloud, Nextcloud, Radicale, Baikal, etc.) und wähle, welche Kalender synchronisiert werden.", + "caldavAddAccount": "CalDAV-Konto hinzufügen", + "caldavEmptyState": "Noch keine CalDAV-Konten verbunden. Füge dein erstes Konto hinzu, um zu starten.", + "caldavNameLabel": "Kontoname", + "caldavNamePlaceholder": "z.B. Mein Radicale, iCloud, Nextcloud", + "caldavUrlLabel": "CalDAV Server-URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "caldavUrlHint": "Die Basis-URL deines CalDAV-Servers", + "caldavUsernameLabel": "Benutzername", + "caldavPasswordLabel": "Passwort", + "caldavPasswordHint": "Für iCloud: App-spezifisches Passwort von appleid.apple.com verwenden", + "caldavAccountAdded": "CalDAV-Konto erfolgreich hinzugefügt", + "caldavAccountDeleted": "CalDAV-Konto entfernt", + "caldavCalendarsToggle": "Kalender anzeigen/ausblenden", + "caldavRefreshCalendars": "Kalender aktualisieren", + "caldavSyncSuccess": "CalDAV-Synchronisation erfolgreich", + "caldavSyncFailed": "CalDAV-Synchronisation fehlgeschlagen", + "caldavConnectionFailed": "Verbindung zum CalDAV-Server fehlgeschlagen" }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", diff --git a/public/locales/en.json b/public/locales/en.json index b90568e..532827b 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -476,6 +476,9 @@ "iconMoon": "Night", "iconWeather": "Weather", "invalidDate": "Use a valid date in the selected date format.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", "attachmentLabel": "Attachment", "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", "attachmentFallback": "Attachment", @@ -982,7 +985,26 @@ "backupSchedulerNever": "No backup created yet", "backupSchedulerTrigger": "Create backup now", "backupSchedulerTriggering": "Creating backup...", - "backupSchedulerTriggeredToast": "Backup created successfully." + "backupSchedulerTriggeredToast": "Backup created successfully.", + "caldavTitle": "CalDAV Calendars", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavAddAccount": "Add CalDAV Account", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavUrlLabel": "CalDAV Server URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "caldavPasswordLabel": "Password", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavSyncFailed": "CalDAV sync failed", + "caldavConnectionFailed": "Connection to CalDAV server failed" }, "login": { "tagline": "Family planning. Secure. Privacy-friendly. Open source.", From 3c9b2840e7f534cf953445d8088988ea866e85f2 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 4 May 2026 08:41:02 +0200 Subject: [PATCH 08/10] feat(caldav): add Settings UI and Event Modal CalDAV target selection - Add CalDAV accounts card to Settings page with: * List of configured accounts showing URL and last sync * Expandable calendar list with enable/disable checkboxes * Sync Now, Refresh Calendars, and Delete actions per account * Add Account modal with name, URL, username, password fields - Add CalDAV target selector to event modal: * Dropdown showing local and all enabled CalDAV calendars * Grouped by account using optgroups * Pre-selects current target when editing events * Includes target_caldav_account_id and target_caldav_calendar_url in save - Add CalDAV component styles to settings.css: * Account cards with header, meta, and action sections * Expandable calendar details with checkboxes and color dots * Empty state for no accounts - Add missing i18n keys for calendar enable/disable, refresh, delete confirm - Load CalDAV targets async when modal opens - Admin-only access to account management Co-Authored-By: Claude Opus 4.7 --- public/locales/de.json | 7 +- public/pages/calendar.js | 82 +++++++++++++ public/pages/settings.js | 231 +++++++++++++++++++++++++++++++++++++ public/styles/settings.css | 119 +++++++++++++++++++ 4 files changed, 438 insertions(+), 1 deletion(-) diff --git a/public/locales/de.json b/public/locales/de.json index dd9025c..29466b8 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -1010,7 +1010,12 @@ "caldavRefreshCalendars": "Kalender aktualisieren", "caldavSyncSuccess": "CalDAV-Synchronisation erfolgreich", "caldavSyncFailed": "CalDAV-Synchronisation fehlgeschlagen", - "caldavConnectionFailed": "Verbindung zum CalDAV-Server fehlgeschlagen" + "caldavConnectionFailed": "Verbindung zum CalDAV-Server fehlgeschlagen", + "calendarEnabled": "Kalender aktiviert", + "calendarDisabled": "Kalender deaktiviert", + "calendarsRefreshed": "Kalender aktualisiert", + "deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.", + "lastSync": "Zuletzt synchronisiert" }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 5611262..190ce48 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -1253,6 +1253,59 @@ function bindTimeInputs(root) { }); } +// -------------------------------------------------------- +// CalDAV Target Helpers +// -------------------------------------------------------- + +async function loadCalDAVTargets(selectElement, currentEvent = null) { + if (!selectElement) return; + + try { + const accountsRes = await api.get('/calendar/caldav/accounts'); + const accounts = accountsRes.data || []; + + // Keep only the "local" option + selectElement.replaceChildren(); + const localOption = document.createElement('option'); + localOption.value = ''; + localOption.textContent = t('calendar.caldavTargetLocal'); + selectElement.appendChild(localOption); + + // Load calendars for each account and build options + for (const account of accounts) { + try { + const calendarsRes = await api.get(`/calendar/caldav/accounts/${account.id}/calendars`); + const calendars = calendarsRes.data || []; + const enabledCalendars = calendars.filter((cal) => cal.enabled); + + if (enabledCalendars.length === 0) continue; + + const optgroup = document.createElement('optgroup'); + optgroup.label = account.name; + + for (const calendar of enabledCalendars) { + const option = document.createElement('option'); + option.value = `${account.id}|${calendar.url}`; + option.textContent = calendar.display_name || calendar.url; + optgroup.appendChild(option); + } + + selectElement.appendChild(optgroup); + } catch (err) { + console.warn(`Failed to load calendars for account ${account.id}:`, err); + } + } + + // Pre-select current event's target if editing + if (currentEvent?.target_caldav_account_id && currentEvent?.target_caldav_calendar_url) { + const targetValue = `${currentEvent.target_caldav_account_id}|${currentEvent.target_caldav_calendar_url}`; + selectElement.value = targetValue; + } + } catch (err) { + console.warn('Failed to load CalDAV targets:', err); + } +} + // -------------------------------------------------------- // Event-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- @@ -1465,6 +1518,12 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom'; }); + // Load CalDAV targets + const caldavTargetSelect = panel.querySelector('#event-caldav-target'); + if (caldavTargetSelect) { + loadCalDAVTargets(caldavTargetSelect, event); + } + panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { @@ -1611,6 +1670,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { +
+ + + ${t('calendar.caldavTargetHint')} +
+