diff --git a/README.md b/README.md index 1131022..81a9b96 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The goal is a single, private place for everything that keeps a household runnin | **Shopping Lists** | Collaborative lists organized by aisle. Import ingredients from meal plans in one click. | | **Meal Planning** | Weekly drag-and-drop planner. Export ingredient lists directly to your shopping list. | | **Recipes** | Create, duplicate, and scale reusable recipes. Pre-fill meal slots from a recipe or save any meal as a recipe. | -| **Calendar** | Two-way sync with Google Calendar (OAuth) and Apple iCloud (CalDAV). Subscribe to any public ICS/webcal URL with per-subscription color and visibility. Overlapping timed events render side-by-side. Events support file attachments (images, PDFs, Office documents). | +| **Calendar** | Two-way sync with Google Calendar (OAuth) and multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal). Per-account calendar selection with checkboxes. Subscribe to any public ICS/webcal URL with per-subscription color and visibility. Overlapping timed events render side-by-side. Events support file attachments (images, PDFs, Office documents). | | **Documents** | Upload and manage family files (PDF, images, Office documents up to 5 MB). Grid/list view, drag-and-drop upload, 14 category tags (medical, school, identity, finance, and more), per-document visibility (family, selected members, private), archive and download. | | **Budget** | Track income and expenses with recurring entries, monthly trends, and CSV export. 35 predefined categories plus custom ones. Supports 15 currencies. Loans tab for instalment-based loan tracking with per-payment history and automatic paid-off detection. | | **Notes & Contacts** | Colored sticky notes with Markdown support. Contact directory with vCard import/export. | diff --git a/docs/SPEC.md b/docs/SPEC.md index 49588fe..a2ba801 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -112,7 +112,7 @@ Reusable recipe cards that can be pre-filled into meal slots. | assigned_to | INTEGER | FK → Users | | created_by | INTEGER | FK → Users, NOT NULL | | external_calendar_id | TEXT | ID from external calendar | -| external_source | TEXT | local, google, apple, ics | +| external_source | TEXT | local, google, apple, ics, caldav | | recurrence_rule | TEXT | iCal RRULE | | subscription_id | INTEGER | FK → ICS Subscriptions (CASCADE delete) | | user_modified | INTEGER | 0/1 — prevents sync overwrite when 1 | @@ -121,18 +121,50 @@ Reusable recipe cards that can be pre-filled into meal slots. | attachment_mime | TEXT | MIME type (e.g. image/jpeg, application/pdf), nullable | | attachment_size | INTEGER | File size in bytes, nullable | | attachment_data | TEXT | Base64 data URL of attachment (≤ 5 MB), nullable | +| target_caldav_account_id | INTEGER | FK → CalDAV Accounts (for outbound sync), nullable | +| target_caldav_calendar_url | TEXT | CalDAV calendar URL (for outbound sync), nullable | ### External Calendars -Display metadata (name, color) for synced Google/Apple calendars. Populated automatically during sync. +Display metadata (name, color) for synced Google/Apple/CalDAV calendars. Populated automatically during sync. | Column | Type | Constraint | |--------|------|-----------| -| source | TEXT | 'google' or 'apple', NOT NULL | +| source | TEXT | 'google', 'apple', or 'caldav', NOT NULL | | external_id | TEXT | Calendar ID from the provider, NOT NULL | | name | TEXT | Display name from the provider, NOT NULL | | color | TEXT | Background color from the provider (HEX) | | UNIQUE | | (source, external_id) | +### CalDAV Accounts +Multi-account CalDAV integration. Stores credentials for CalDAV servers (iCloud, Nextcloud, Radicale, Baikal, etc.). + +| Column | Type | Constraint | +|--------|------|-----------| +| id | INTEGER | PRIMARY KEY AUTOINCREMENT | +| name | TEXT | User-defined label (e.g. "My Radicale", "iCloud"), NOT NULL | +| caldav_url | TEXT | CalDAV server base URL, NOT NULL | +| username | TEXT | CalDAV username, NOT NULL | +| password | TEXT | CalDAV password (encrypted if DB_ENCRYPTION_KEY set), NOT NULL | +| created_at | TEXT | ISO 8601 | +| last_sync | TEXT | ISO 8601, nullable | +| UNIQUE | | (caldav_url, username) | + +### CalDAV Calendar Selection +Per-account calendar enable/disable state for CalDAV accounts. + +| Column | Type | Constraint | +|--------|------|-----------| +| id | INTEGER | PRIMARY KEY AUTOINCREMENT | +| account_id | INTEGER | FK → CalDAV Accounts (CASCADE delete), NOT NULL | +| calendar_url | TEXT | CalDAV calendar URL from provider, NOT NULL | +| calendar_name | TEXT | Display name from provider, NOT NULL | +| calendar_color | TEXT | HEX color code from provider, nullable | +| enabled | INTEGER | 0/1 (default 1), controls sync for this calendar | +| created_at | TEXT | ISO 8601 | +| UNIQUE | | (account_id, calendar_url) | + +Index: CREATE INDEX idx_caldav_selection_enabled ON caldav_calendar_selection(account_id, enabled) + ### Notes | Column | Type | Constraint | |--------|------|-----------| @@ -393,7 +425,7 @@ Reusable recipe cards linked to meal slots. - Color-coding per person - Recurring via iCal RRULE - **Google Calendar:** OAuth 2.0, Calendar API v3, two-way sync -- **Apple Calendar:** CalDAV (tsdav), two-way sync +- **CalDAV Multi-Account:** Connect multiple CalDAV servers (iCloud, Nextcloud, Radicale, Baikal) with per-account calendar selection via checkboxes, two-way sync (tsdav), optional outbound target selection per event - **ICS Subscriptions:** Subscribe to any public ICS/webcal URL (e.g. public holidays, sports schedules). Per-subscription color, private/shared visibility, manual "Sync now" and automatic sync on the shared interval. Edit name, color, and visibility of any subscription inline. RRULE events expanded into a rolling ±6/+12 month window. SSRF-protected (DNS pre-resolution), ETag/Last-Modified conditional fetch, 10 MB limit, 15 s timeout. User-edited events are protected from being overwritten (`user_modified`); a "Reset to original" link restores them. - **External calendar names & colors:** Google and Apple sync stores each calendar's display name and background color in the `external_calendars` table (migration v14). A colored `event-cal-label` badge appears in event popups, agenda, month, week, and day views when `cal_name` is present. - **Event location:** Event popup and dashboard display the location field with RFC 5545 backslash-escape normalization (`\n`, `\,`, `\;`, `\\`) via `fmtLocation()` in `public/utils/html.js`. diff --git a/package.json b/package.json index f9410c2..0ed7384 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test:ics-parser": "node test-ics-parser.js", "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler" + "test:caldav": "node --experimental-sqlite test-caldav-sync.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/public/locales/de.json b/public/locales/de.json index ee51751..29466b8 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -492,6 +492,9 @@ "iconMoon": "Nacht", "iconWeather": "Wetter", "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.", + "caldavTargetLabel": "Zu CalDAV synchronisieren", + "caldavTargetLocal": "Nur lokal speichern", + "caldavTargetHint": "Wähle einen CalDAV-Kalender, um diesen Termin zu synchronisieren.", "attachmentLabel": "Anhang", "attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.", "attachmentFallback": "Anhang", @@ -988,7 +991,31 @@ "backupSchedulerNever": "Noch kein Backup erstellt", "backupSchedulerTrigger": "Jetzt Backup erstellen", "backupSchedulerTriggering": "Backup wird erstellt...", - "backupSchedulerTriggeredToast": "Backup erfolgreich erstellt." + "backupSchedulerTriggeredToast": "Backup erfolgreich erstellt.", + "caldavTitle": "CalDAV Kalender", + "caldavDescription": "Verbinde mehrere CalDAV-Konten (iCloud, Nextcloud, Radicale, Baikal, etc.) und wähle, welche Kalender synchronisiert werden.", + "caldavAddAccount": "CalDAV-Konto hinzufügen", + "caldavEmptyState": "Noch keine CalDAV-Konten verbunden. Füge dein erstes Konto hinzu, um zu starten.", + "caldavNameLabel": "Kontoname", + "caldavNamePlaceholder": "z.B. Mein Radicale, iCloud, Nextcloud", + "caldavUrlLabel": "CalDAV Server-URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "caldavUrlHint": "Die Basis-URL deines CalDAV-Servers", + "caldavUsernameLabel": "Benutzername", + "caldavPasswordLabel": "Passwort", + "caldavPasswordHint": "Für iCloud: App-spezifisches Passwort von appleid.apple.com verwenden", + "caldavAccountAdded": "CalDAV-Konto erfolgreich hinzugefügt", + "caldavAccountDeleted": "CalDAV-Konto entfernt", + "caldavCalendarsToggle": "Kalender anzeigen/ausblenden", + "caldavRefreshCalendars": "Kalender aktualisieren", + "caldavSyncSuccess": "CalDAV-Synchronisation erfolgreich", + "caldavSyncFailed": "CalDAV-Synchronisation fehlgeschlagen", + "caldavConnectionFailed": "Verbindung zum CalDAV-Server fehlgeschlagen", + "calendarEnabled": "Kalender aktiviert", + "calendarDisabled": "Kalender deaktiviert", + "calendarsRefreshed": "Kalender aktualisiert", + "deleteAccountConfirm": "CalDAV-Konto wirklich löschen? Alle synchronisierten Kalender werden entfernt.", + "lastSync": "Zuletzt synchronisiert" }, "login": { "tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.", diff --git a/public/locales/en.json b/public/locales/en.json index b90568e..44c0295 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -476,6 +476,9 @@ "iconMoon": "Night", "iconWeather": "Weather", "invalidDate": "Use a valid date in the selected date format.", + "caldavTargetLabel": "Sync to CalDAV", + "caldavTargetLocal": "Store locally only", + "caldavTargetHint": "Choose a CalDAV calendar to sync this event.", "attachmentLabel": "Attachment", "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", "attachmentFallback": "Attachment", @@ -982,7 +985,31 @@ "backupSchedulerNever": "No backup created yet", "backupSchedulerTrigger": "Create backup now", "backupSchedulerTriggering": "Creating backup...", - "backupSchedulerTriggeredToast": "Backup created successfully." + "backupSchedulerTriggeredToast": "Backup created successfully.", + "caldavTitle": "CalDAV Calendars", + "caldavDescription": "Connect multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal, etc.) and choose which calendars to sync.", + "caldavAddAccount": "Add CalDAV Account", + "caldavEmptyState": "No CalDAV accounts connected yet. Add your first account to get started.", + "caldavNameLabel": "Account Name", + "caldavNamePlaceholder": "e.g. My Radicale, iCloud, Nextcloud", + "caldavUrlLabel": "CalDAV Server URL", + "caldavUrlPlaceholder": "https://caldav.icloud.com", + "caldavUrlHint": "The base URL of your CalDAV server", + "caldavUsernameLabel": "Username", + "caldavPasswordLabel": "Password", + "caldavPasswordHint": "For iCloud: Use app-specific password from appleid.apple.com", + "caldavAccountAdded": "CalDAV account added successfully", + "caldavAccountDeleted": "CalDAV account removed", + "caldavCalendarsToggle": "Show/hide calendars", + "caldavRefreshCalendars": "Refresh calendars", + "caldavSyncSuccess": "CalDAV sync successful", + "caldavSyncFailed": "CalDAV sync failed", + "caldavConnectionFailed": "Connection to CalDAV server failed", + "calendarEnabled": "Calendar enabled", + "calendarDisabled": "Calendar disabled", + "calendarsRefreshed": "Calendars refreshed", + "deleteAccountConfirm": "Really delete CalDAV account? All synced calendars will be removed.", + "lastSync": "Last synced" }, "login": { "tagline": "Family planning. Secure. Privacy-friendly. Open source.", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 5611262..190ce48 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -1253,6 +1253,59 @@ function bindTimeInputs(root) { }); } +// -------------------------------------------------------- +// CalDAV Target Helpers +// -------------------------------------------------------- + +async function loadCalDAVTargets(selectElement, currentEvent = null) { + if (!selectElement) return; + + try { + const accountsRes = await api.get('/calendar/caldav/accounts'); + const accounts = accountsRes.data || []; + + // Keep only the "local" option + selectElement.replaceChildren(); + const localOption = document.createElement('option'); + localOption.value = ''; + localOption.textContent = t('calendar.caldavTargetLocal'); + selectElement.appendChild(localOption); + + // Load calendars for each account and build options + for (const account of accounts) { + try { + const calendarsRes = await api.get(`/calendar/caldav/accounts/${account.id}/calendars`); + const calendars = calendarsRes.data || []; + const enabledCalendars = calendars.filter((cal) => cal.enabled); + + if (enabledCalendars.length === 0) continue; + + const optgroup = document.createElement('optgroup'); + optgroup.label = account.name; + + for (const calendar of enabledCalendars) { + const option = document.createElement('option'); + option.value = `${account.id}|${calendar.url}`; + option.textContent = calendar.display_name || calendar.url; + optgroup.appendChild(option); + } + + selectElement.appendChild(optgroup); + } catch (err) { + console.warn(`Failed to load calendars for account ${account.id}:`, err); + } + } + + // Pre-select current event's target if editing + if (currentEvent?.target_caldav_account_id && currentEvent?.target_caldav_calendar_url) { + const targetValue = `${currentEvent.target_caldav_account_id}|${currentEvent.target_caldav_calendar_url}`; + selectElement.value = targetValue; + } + } catch (err) { + console.warn('Failed to load CalDAV targets:', err); + } +} + // -------------------------------------------------------- // Event-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- @@ -1465,6 +1518,12 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom'; }); + // Load CalDAV targets + const caldavTargetSelect = panel.querySelector('#event-caldav-target'); + if (caldavTargetSelect) { + loadCalDAVTargets(caldavTargetSelect, event); + } + panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { @@ -1611,6 +1670,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { +
+ + + ${t('calendar.caldavTargetHint')} +
+