feat: Schritte 14–15 — Google Calendar OAuth + Apple CalDAV Sync + Settings-Seite

- server/services/google-calendar.js: OAuth 2.0, bidirektionaler Sync via
  Google Calendar API v3, inkrementeller syncToken, 410-Fallback auf Vollsync
- server/services/apple-calendar.js: CalDAV via tsdav (dynamic ESM import),
  minimaler ICS-Parser + ICS-Builder, bidirektionaler Sync
- server/routes/calendar.js: 7 neue Sync-Routen (google/auth, google/callback,
  google/sync, google/status, google/disconnect, apple/status, apple/sync)
- server/db.js: Migration 2 — sync_config Tabelle + idx_calendar_external_id
- server/db-schema-test.js: MIGRATIONS_SQL[2] für Tests synchronisiert
- server/auth.js: PATCH /me/password Endpoint
- server/index.js: Auto-Sync-Scheduler (setInterval, SYNC_INTERVAL_MINUTES)
- public/pages/settings.js: vollständige Settings-Seite (Konto, Passwort,
  Kalender-Sync-Status + Aktionen, Familienmitglieder-Verwaltung)
- public/styles/settings.css: neue Stylesheet-Datei
- public/index.html + public/sw.js: settings.css eingebunden und gecacht
- .env.example: SYNC_INTERVAL_MINUTES ergänzt
- README.md: vollständige Setup-Anleitung, Google/Apple-Sync-Dokumentation,
  modernes GitHub-Layout mit Badges und aufklappbaren Abschnitten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 22:53:44 +01:00
parent 81d4000ee1
commit 72d6d5126e
13 changed files with 1693 additions and 131 deletions
+286 -5
View File
@@ -1,11 +1,292 @@
/**
* Modul: Apple Calendar Sync (CalDAV)
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
* Abhängigkeiten: tsdav, server/db.js
* 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
*/
// Platzhalter — wird in Phase 3 implementiert
'use strict';
module.exports = {
sync: async () => null,
};
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 };