/** * Modul: Apple Calendar Sync (CalDAV) * Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll * Abhängigkeiten: tsdav (ESM — dynamisch importiert), server/db.js * * Konfiguration (.env): * APPLE_CALDAV_URL — z.B. https://caldav.icloud.com * APPLE_USERNAME — Apple-ID E-Mail * APPLE_APP_SPECIFIC_PASSWORD — App-spezifisches Passwort aus appleid.apple.com * * sync_config-Schlüssel: * apple_last_sync — ISO-8601-Timestamp des letzten Syncs */ 'use strict'; const db = require('../db'); const APPLE_COLOR = '#FC3C44'; // -------------------------------------------------------- // sync_config Helfer // -------------------------------------------------------- function cfgGet(key) { const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key); return row ? row.value : null; } function cfgSet(key, value) { db.get().prepare(` INSERT INTO sync_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') `).run(key, value); } // -------------------------------------------------------- // Verbindungsstatus // -------------------------------------------------------- function getStatus() { const configured = !!( process.env.APPLE_CALDAV_URL && process.env.APPLE_USERNAME && process.env.APPLE_APP_SPECIFIC_PASSWORD ); const lastSync = cfgGet('apple_last_sync'); return { configured, lastSync }; } // -------------------------------------------------------- // Minimaler ICS-Parser // -------------------------------------------------------- /** * Entfaltet ICS-Zeilenfortsetzungen (RFC 5545 §3.1). * @param {string} ics * @returns {string} */ function unfoldLines(ics) { return ics.replace(/\r?\n[ \t]/g, ''); } /** * Extrahiert alle VEVENT-Blöcke aus einem ICS-String. * @param {string} ics * @returns {Array<{uid, summary, description, location, dtstart, dtend, rrule, allDay}>} */ function parseICS(ics) { const unfolded = unfoldLines(ics); const events = []; const vEventRe = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/g; let match; while ((match = vEventRe.exec(unfolded)) !== null) { const block = match[1]; const get = (prop) => { const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im'); const m = re.exec(block); return m ? m[1].trim() : null; }; const uid = get('UID'); const summary = get('SUMMARY') || '(kein Titel)'; const description = get('DESCRIPTION') || null; const location = get('LOCATION') || null; const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null; // DTSTART — mit optionalem TZID oder VALUE=DATE const dtStartRaw = (() => { const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block); return m ? m[1].trim() : null; })(); const dtEndRaw = (() => { const m = /^DTEND(?:;[^:]*)?:(.*)$/im.exec(block); return m ? m[1].trim() : null; })(); const allDay = /^DTSTART;VALUE=DATE:/im.test(block); const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null; const dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null; if (!uid || !dtstart) continue; events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay }); } return events; } /** * Konvertiert ICS-Datumswert in ISO-8601-String. * Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000), * DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (ignoriert TZID, behandelt als lokal). * @param {string} val * @param {boolean} allDay * @returns {string} */ function formatICSDate(val, allDay) { if (allDay || /^\d{8}$/.test(val)) { // DATE: YYYYMMDD → YYYY-MM-DD return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`; } // DATE-TIME: YYYYMMDDTHHMMSS[Z] const y = val.slice(0, 4); const mo = val.slice(4, 6); const d = val.slice(6, 8); const h = val.slice(9, 11); const mi = val.slice(11, 13); const s = val.slice(13, 15) || '00'; const z = val.endsWith('Z') ? 'Z' : ''; return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`; } // -------------------------------------------------------- // Minimaler ICS-Builder // -------------------------------------------------------- /** * Erstellt einen minimalen ICS-String für ein lokales Event. * @param {{ id, title, description, start_datetime, end_datetime, all_day, location, recurrence_rule }} event * @returns {string} */ function buildICS(event) { const uid = `oikos-${event.id}@oikos.local`; const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Oikos//Familienplaner//DE', 'BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${now}`, `SUMMARY:${escapeICS(event.title)}`, ]; if (event.all_day) { const startDate = event.start_datetime.slice(0, 10).replace(/-/g, ''); const endDate = (event.end_datetime || event.start_datetime).slice(0, 10).replace(/-/g, ''); lines.push(`DTSTART;VALUE=DATE:${startDate}`); lines.push(`DTEND;VALUE=DATE:${endDate}`); } else { const startDt = event.start_datetime.replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const endDt = (event.end_datetime || event.start_datetime).replace(/[-:]/g, '').replace(/\.\d{3}/, ''); lines.push(`DTSTART:${startDt}`); lines.push(`DTEND:${endDt}`); } if (event.description) lines.push(`DESCRIPTION:${escapeICS(event.description)}`); if (event.location) lines.push(`LOCATION:${escapeICS(event.location)}`); if (event.recurrence_rule) lines.push(event.recurrence_rule); // z.B. RRULE:FREQ=WEEKLY;BYDAY=MO lines.push('END:VEVENT', 'END:VCALENDAR'); return lines.join('\r\n'); } function escapeICS(str) { return String(str).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n'); } // -------------------------------------------------------- // Sync // -------------------------------------------------------- /** * Bidirektionaler CalDAV-Sync mit iCloud. * Inbound: iCloud → lokale DB (Upsert via external_calendar_id = UID) * Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud */ async function sync() { const caldavUrl = process.env.APPLE_CALDAV_URL; const username = process.env.APPLE_USERNAME; const password = process.env.APPLE_APP_SPECIFIC_PASSWORD; if (!caldavUrl || !username || !password) { throw new Error('[Apple] APPLE_CALDAV_URL, APPLE_USERNAME und APPLE_APP_SPECIFIC_PASSWORD müssen gesetzt sein.'); } // tsdav ist ESM-only — dynamischer Import aus CommonJS 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) { console.warn('[Apple] Keine Kalender gefunden.'); return; } // Standard-Kalender: erster nicht-Geburtstags-Kalender const cal = calendars.find((c) => !c.displayName?.toLowerCase().includes('geburts')) || calendars[0]; const calObjects = await client.fetchCalendarObjects({ calendar: cal }); // -------------------------------------------------------- // Inbound: iCloud → lokal // -------------------------------------------------------- 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 = 'apple'` ).get(ev.uid); if (existing) { db.get().prepare(` UPDATE calendar_events SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?, location = ?, recurrence_rule = ? WHERE id = ? `).run( ev.summary, ev.description, ev.dtstart, ev.dtend, ev.allDay ? 1 : 0, ev.location, ev.rrule, existing.id ); } else { db.get().prepare(` INSERT INTO calendar_events (title, description, start_datetime, end_datetime, all_day, location, color, external_calendar_id, external_source, recurrence_rule, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'apple', ?, 1) `).run( ev.summary, ev.description, ev.dtstart, ev.dtend, ev.allDay ? 1 : 0, ev.location, APPLE_COLOR, ev.uid, ev.rrule ); } } catch (err) { console.error(`[Apple] Upsert-Fehler für UID ${ev.uid}:`, err.message); } } } // -------------------------------------------------------- // Outbound: lokal → iCloud // -------------------------------------------------------- const localEvents = db.get().prepare(` SELECT * FROM calendar_events WHERE external_source = 'local' AND external_calendar_id IS NULL `).all(); for (const event of localEvents) { try { const icsData = buildICS(event); const uid = `oikos-${event.id}@oikos.local`; const filename = `${uid}.ics`; await client.createCalendarObject({ calendar: cal, filename, iCalString: icsData, }); db.get().prepare(` UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ? `).run(uid, event.id); } catch (err) { console.error(`[Apple] Outbound-Fehler für Event ${event.id}:`, err.message); } } cfgSet('apple_last_sync', new Date().toISOString()); console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`); } module.exports = { sync, getStatus };