eafe2b964d
The Apple Calendar sync hardcoded created_by=1 which fails when no user with ID 1 exists, causing every single event import to fail silently. Now dynamically resolves the first available user. Also syncs all calendars instead of only the first one, adds the missing cfgDel helper, and gracefully skips unreachable calendars. Fixes #4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
|
|
function cfgDel(key) {
|
|
db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Credentials: sync_config hat Vorrang vor .env
|
|
// --------------------------------------------------------
|
|
|
|
function getCredentials() {
|
|
const url = cfgGet('apple_caldav_url') || process.env.APPLE_CALDAV_URL;
|
|
const username = cfgGet('apple_username') || process.env.APPLE_USERNAME;
|
|
const password = cfgGet('apple_app_password') || process.env.APPLE_APP_SPECIFIC_PASSWORD;
|
|
if (!url || !username || !password) return null;
|
|
return { url, username, password };
|
|
}
|
|
|
|
function saveCredentials(url, username, password) {
|
|
cfgSet('apple_caldav_url', url);
|
|
cfgSet('apple_username', username);
|
|
cfgSet('apple_app_password', password);
|
|
}
|
|
|
|
function clearCredentials() {
|
|
['apple_caldav_url', 'apple_username', 'apple_app_password', 'apple_last_sync'].forEach(cfgDel);
|
|
console.log('[Apple] Verbindung getrennt.');
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Verbindungsstatus
|
|
// --------------------------------------------------------
|
|
|
|
function getStatus() {
|
|
const creds = getCredentials();
|
|
const configured = !!creds;
|
|
const connected = !!(cfgGet('apple_caldav_url')); // via UI gespeichert
|
|
const lastSync = cfgGet('apple_last_sync');
|
|
return { configured, connected, lastSync };
|
|
}
|
|
|
|
/**
|
|
* Verbindungstest: CalDAV-Client erstellen und Kalender abrufen.
|
|
* Wirft einen Fehler wenn die Credentials ungültig sind.
|
|
*/
|
|
async function testConnection() {
|
|
const creds = getCredentials();
|
|
if (!creds) throw new Error('[Apple] Keine Credentials konfiguriert.');
|
|
|
|
const { createDAVClient } = await import('tsdav');
|
|
const client = await createDAVClient({
|
|
serverUrl: creds.url,
|
|
credentials: { username: creds.username, password: creds.password },
|
|
authMethod: 'Basic',
|
|
defaultAccountType: 'caldav',
|
|
});
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
if (!calendars.length) throw new Error('[Apple] Verbunden, aber keine Kalender gefunden.');
|
|
return { ok: true, calendarCount: calendars.length };
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// 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 creds = getCredentials();
|
|
if (!creds) {
|
|
throw new Error('[Apple] Keine Credentials konfiguriert (weder in DB noch in .env).');
|
|
}
|
|
|
|
// tsdav ist ESM-only — dynamischer Import aus CommonJS
|
|
const { createDAVClient } = await import('tsdav');
|
|
|
|
const client = await createDAVClient({
|
|
serverUrl: creds.url,
|
|
credentials: { username: creds.username, password: creds.password },
|
|
authMethod: 'Basic',
|
|
defaultAccountType: 'caldav',
|
|
});
|
|
|
|
const calendars = await client.fetchCalendars();
|
|
if (!calendars.length) {
|
|
console.warn('[Apple] Keine Kalender gefunden.');
|
|
return;
|
|
}
|
|
|
|
// created_by: ersten existierenden User verwenden (nicht hardcoded ID 1)
|
|
const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get();
|
|
if (!owner) {
|
|
console.warn('[Apple] Kein User in der Datenbank — Sync übersprungen.');
|
|
return;
|
|
}
|
|
const createdBy = owner.id;
|
|
|
|
// Alle Kalender synchen (außer Geburtstags-Kalender)
|
|
const syncCalendars = calendars.filter(
|
|
(c) => !c.displayName?.toLowerCase().includes('geburts') &&
|
|
!c.displayName?.toLowerCase().includes('birthday')
|
|
);
|
|
|
|
let totalObjects = 0;
|
|
|
|
for (const cal of syncCalendars) {
|
|
let calObjects;
|
|
try {
|
|
calObjects = await client.fetchCalendarObjects({ calendar: cal });
|
|
} catch (err) {
|
|
console.warn(`[Apple] Kalender "${cal.displayName || '(unbenannt)'}" nicht abrufbar: ${err.message}`);
|
|
continue;
|
|
}
|
|
|
|
totalObjects += calObjects.length;
|
|
|
|
// --------------------------------------------------------
|
|
// 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', ?, ?)
|
|
`).run(
|
|
ev.summary, ev.description, ev.dtstart, ev.dtend,
|
|
ev.allDay ? 1 : 0, ev.location, APPLE_COLOR, ev.uid, ev.rrule, createdBy
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Apple] Upsert-Fehler für UID ${ev.uid}:`, err.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Outbound: lokal → iCloud (erster verfügbarer Kalender)
|
|
// --------------------------------------------------------
|
|
const defaultCal = syncCalendars[0];
|
|
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: defaultCal,
|
|
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 — ${totalObjects} Objekte aus ${syncCalendars.length} Kalendern inbound, ${localEvents.length} lokal → iCloud.`);
|
|
}
|
|
|
|
module.exports = { sync, getStatus, saveCredentials, clearCredentials, testConnection };
|