Files
Ulas Kalayci c5a9799983 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 <noreply@anthropic.com>
2026-05-04 08:32:46 +02:00

540 lines
17 KiB
JavaScript

/**
* 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 };
}
// --------------------------------------------------------
// 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
// --------------------------------------------------------
export {
addAccount,
listAccounts,
updateAccount,
deleteAccount,
getCalendars,
updateCalendarSelection,
sync,
getStatus
};