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 };