feat: generic CalDAV multi-account sync (#90)
Replaces single Apple CalDAV with generic multi-account CalDAV integration. Features: - Multiple CalDAV accounts (iCloud, Nextcloud, Radicale, Baikal) - Per-account calendar selection via checkboxes - Bidirectional sync (CalDAV ↔ Oikos) - Optional outbound target selection per event - Migration of existing Apple CalDAV data Technical: - New tables: caldav_accounts, caldav_calendar_selection - New service: server/services/caldav-sync.js - New API routes: /calendar/caldav/* - Enhanced UI in Settings and Calendar event modal - 7 new tests, all passing Closes #90 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Notes & Contacts** | Colored sticky notes with Markdown support. Contact directory with vCard import/export. |
|
||||||
|
|||||||
+36
-4
@@ -112,7 +112,7 @@ Reusable recipe cards that can be pre-filled into meal slots.
|
|||||||
| assigned_to | INTEGER | FK → Users |
|
| assigned_to | INTEGER | FK → Users |
|
||||||
| created_by | INTEGER | FK → Users, NOT NULL |
|
| created_by | INTEGER | FK → Users, NOT NULL |
|
||||||
| external_calendar_id | TEXT | ID from external calendar |
|
| 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 |
|
| recurrence_rule | TEXT | iCal RRULE |
|
||||||
| subscription_id | INTEGER | FK → ICS Subscriptions (CASCADE delete) |
|
| subscription_id | INTEGER | FK → ICS Subscriptions (CASCADE delete) |
|
||||||
| user_modified | INTEGER | 0/1 — prevents sync overwrite when 1 |
|
| 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_mime | TEXT | MIME type (e.g. image/jpeg, application/pdf), nullable |
|
||||||
| attachment_size | INTEGER | File size in bytes, nullable |
|
| attachment_size | INTEGER | File size in bytes, nullable |
|
||||||
| attachment_data | TEXT | Base64 data URL of attachment (≤ 5 MB), 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
|
### 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 |
|
| 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 |
|
| external_id | TEXT | Calendar ID from the provider, NOT NULL |
|
||||||
| name | TEXT | Display name from the provider, NOT NULL |
|
| name | TEXT | Display name from the provider, NOT NULL |
|
||||||
| color | TEXT | Background color from the provider (HEX) |
|
| color | TEXT | Background color from the provider (HEX) |
|
||||||
| UNIQUE | | (source, external_id) |
|
| 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
|
### Notes
|
||||||
| Column | Type | Constraint |
|
| Column | Type | Constraint |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
@@ -393,7 +425,7 @@ Reusable recipe cards linked to meal slots.
|
|||||||
- Color-coding per person
|
- Color-coding per person
|
||||||
- Recurring via iCal RRULE
|
- Recurring via iCal RRULE
|
||||||
- **Google Calendar:** OAuth 2.0, Calendar API v3, two-way sync
|
- **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.
|
- **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.
|
- **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`.
|
- **Event location:** Event popup and dashboard display the location field with RFC 5545 backslash-escape normalization (`\n`, `\,`, `\;`, `\\`) via `fmtLocation()` in `public/utils/html.js`.
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@
|
|||||||
"test:ics-parser": "node test-ics-parser.js",
|
"test:ics-parser": "node test-ics-parser.js",
|
||||||
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
||||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.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": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+28
-1
@@ -492,6 +492,9 @@
|
|||||||
"iconMoon": "Nacht",
|
"iconMoon": "Nacht",
|
||||||
"iconWeather": "Wetter",
|
"iconWeather": "Wetter",
|
||||||
"invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.",
|
"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",
|
"attachmentLabel": "Anhang",
|
||||||
"attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.",
|
"attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.",
|
||||||
"attachmentFallback": "Anhang",
|
"attachmentFallback": "Anhang",
|
||||||
@@ -988,7 +991,31 @@
|
|||||||
"backupSchedulerNever": "Noch kein Backup erstellt",
|
"backupSchedulerNever": "Noch kein Backup erstellt",
|
||||||
"backupSchedulerTrigger": "Jetzt Backup erstellen",
|
"backupSchedulerTrigger": "Jetzt Backup erstellen",
|
||||||
"backupSchedulerTriggering": "Backup wird erstellt...",
|
"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": {
|
"login": {
|
||||||
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
|
||||||
|
|||||||
+28
-1
@@ -476,6 +476,9 @@
|
|||||||
"iconMoon": "Night",
|
"iconMoon": "Night",
|
||||||
"iconWeather": "Weather",
|
"iconWeather": "Weather",
|
||||||
"invalidDate": "Use a valid date in the selected date format.",
|
"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",
|
"attachmentLabel": "Attachment",
|
||||||
"attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
|
"attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.",
|
||||||
"attachmentFallback": "Attachment",
|
"attachmentFallback": "Attachment",
|
||||||
@@ -982,7 +985,31 @@
|
|||||||
"backupSchedulerNever": "No backup created yet",
|
"backupSchedulerNever": "No backup created yet",
|
||||||
"backupSchedulerTrigger": "Create backup now",
|
"backupSchedulerTrigger": "Create backup now",
|
||||||
"backupSchedulerTriggering": "Creating backup...",
|
"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": {
|
"login": {
|
||||||
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
|
||||||
|
|||||||
@@ -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)
|
// Event-Modal (Erstellen / Bearbeiten)
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -1465,6 +1518,12 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
|||||||
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
|
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-cancel').addEventListener('click', closeModal);
|
||||||
|
|
||||||
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
|
panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
|
||||||
@@ -1611,6 +1670,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="event-caldav-target">${t('calendar.caldavTargetLabel')}</label>
|
||||||
|
<select class="form-input" id="event-caldav-target">
|
||||||
|
<option value="">${t('calendar.caldavTargetLocal')}</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint">${t('calendar.caldavTargetHint')}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
|
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
|
||||||
<textarea class="form-input" id="modal-description" rows="2"
|
<textarea class="form-input" id="modal-description" rows="2"
|
||||||
@@ -1725,6 +1792,19 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
|
|||||||
attachmentPayload.data = await readFileAsDataUrl(attachmentFile);
|
attachmentPayload.data = await readFileAsDataUrl(attachmentFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract CalDAV target
|
||||||
|
const caldavTargetValue = overlay.querySelector('#event-caldav-target')?.value || '';
|
||||||
|
let target_caldav_account_id = null;
|
||||||
|
let target_caldav_calendar_url = null;
|
||||||
|
|
||||||
|
if (caldavTargetValue) {
|
||||||
|
const [accountId, calendarUrl] = caldavTargetValue.split('|');
|
||||||
|
if (accountId && calendarUrl) {
|
||||||
|
target_caldav_account_id = parseInt(accountId, 10);
|
||||||
|
target_caldav_calendar_url = calendarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
title, description, start_datetime, end_datetime,
|
title, description, start_datetime, end_datetime,
|
||||||
all_day: allday ? 1 : 0,
|
all_day: allday ? 1 : 0,
|
||||||
@@ -1734,6 +1814,8 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
|
|||||||
attachment_mime: attachmentPayload.mime,
|
attachment_mime: attachmentPayload.mime,
|
||||||
attachment_size: attachmentPayload.size,
|
attachment_size: attachmentPayload.size,
|
||||||
attachment_data: attachmentPayload.data,
|
attachment_data: attachmentPayload.data,
|
||||||
|
target_caldav_account_id,
|
||||||
|
target_caldav_calendar_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
let savedEventId = eventId;
|
let savedEventId = eventId;
|
||||||
|
|||||||
@@ -529,6 +529,23 @@ export async function render(container, { user }) {
|
|||||||
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
|
` : `<span class="form-hint">${t('settings.appleOnlyAdmin')}</span>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CalDAV Kalender -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h2>${t('settings.caldavTitle')}</h2>
|
||||||
|
<p class="settings-card-description">${t('settings.caldavDescription')}</p>
|
||||||
|
|
||||||
|
<div id="caldav-accounts-list"></div>
|
||||||
|
<div id="caldav-empty-state" class="caldav-empty-state" style="display: none;">
|
||||||
|
<p>${t('settings.caldavEmptyState')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${user?.role === 'admin' ? `
|
||||||
|
<button class="btn btn--primary" id="caldav-add-account-btn">
|
||||||
|
${t('settings.caldavAddAccount')}
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ICS-Abonnements -->
|
<!-- ICS-Abonnements -->
|
||||||
<div class="settings-card" id="ics-card">
|
<div class="settings-card" id="ics-card">
|
||||||
<div class="settings-sync-header">
|
<div class="settings-sync-header">
|
||||||
@@ -1167,6 +1184,220 @@ function bindEvents(container, user, users, categories, icsSubscriptions, apiTok
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalDAV-Konten laden
|
||||||
|
async function loadCalDAVAccounts(container) {
|
||||||
|
const listEl = container.querySelector('#caldav-accounts-list');
|
||||||
|
const emptyEl = container.querySelector('#caldav-empty-state');
|
||||||
|
if (!listEl || !emptyEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountsRes = await api.get('/calendar/caldav/accounts');
|
||||||
|
const accounts = accountsRes.data || [];
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
listEl.replaceChildren();
|
||||||
|
emptyEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyEl.style.display = 'none';
|
||||||
|
listEl.replaceChildren();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const calendarsRes = await api.get(`/calendar/caldav/accounts/${account.id}/calendars`);
|
||||||
|
const calendars = calendarsRes.data || [];
|
||||||
|
|
||||||
|
const accountCard = document.createElement('div');
|
||||||
|
accountCard.className = 'caldav-account-item';
|
||||||
|
accountCard.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="caldav-account-header">
|
||||||
|
<h4>${esc(account.name)}</h4>
|
||||||
|
<div class="caldav-account-meta">
|
||||||
|
<span>${esc(account.caldav_url)}</span>
|
||||||
|
${account.last_sync ? `<span>${t('settings.lastSync')}: ${formatDateTime(account.last_sync)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="caldav-calendars-details">
|
||||||
|
<summary class="caldav-calendars-summary">
|
||||||
|
${t('settings.caldavCalendarsToggle')} (${calendars.length})
|
||||||
|
</summary>
|
||||||
|
<div class="caldav-calendars-list">
|
||||||
|
${calendars.map((cal) => `
|
||||||
|
<label class="caldav-calendar-item">
|
||||||
|
<input type="checkbox" class="caldav-calendar-checkbox"
|
||||||
|
data-account-id="${account.id}"
|
||||||
|
data-calendar-url="${esc(cal.url)}"
|
||||||
|
${cal.enabled ? 'checked' : ''}>
|
||||||
|
<span class="caldav-calendar-color" style="background-color: ${esc(cal.color || '#007AFF')}"></span>
|
||||||
|
<span class="caldav-calendar-name">${esc(cal.display_name || cal.url)}</span>
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="caldav-account-actions">
|
||||||
|
<button class="btn btn--secondary btn--sm" data-caldav-sync="${account.id}">${t('settings.syncNow')}</button>
|
||||||
|
<button class="btn btn--secondary btn--sm" data-caldav-refresh="${account.id}">${t('settings.caldavRefreshCalendars')}</button>
|
||||||
|
${user?.role === 'admin' ? `<button class="btn btn--danger-outline btn--sm" data-caldav-delete="${account.id}">${t('common.delete')}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
listEl.appendChild(accountCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.lucide) lucide.createIcons({ el: listEl });
|
||||||
|
|
||||||
|
// Bind calendar checkbox events
|
||||||
|
listEl.querySelectorAll('.caldav-calendar-checkbox').forEach((checkbox) => {
|
||||||
|
checkbox.addEventListener('change', async () => {
|
||||||
|
const accountId = parseInt(checkbox.dataset.accountId, 10);
|
||||||
|
const calendarUrl = checkbox.dataset.calendarUrl;
|
||||||
|
const enabled = checkbox.checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.patch(`/calendar/caldav/accounts/${accountId}/calendars`, {
|
||||||
|
calendarUrl,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
window.oikos?.showToast(
|
||||||
|
enabled ? t('settings.calendarEnabled') : t('settings.calendarDisabled'),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
checkbox.checked = !enabled; // Revert on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind sync buttons
|
||||||
|
listEl.querySelectorAll('[data-caldav-sync]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = t('settings.synchronizing');
|
||||||
|
try {
|
||||||
|
await api.post('/calendar/caldav/sync');
|
||||||
|
window.oikos?.showToast(t('settings.caldavSyncSuccess'), 'success');
|
||||||
|
await loadCalDAVAccounts(container);
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message || t('settings.caldavSyncFailed'), 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind refresh buttons
|
||||||
|
listEl.querySelectorAll('[data-caldav-refresh]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const accountId = parseInt(btn.dataset.caldavRefresh, 10);
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = t('settings.loading');
|
||||||
|
try {
|
||||||
|
await api.get(`/calendar/caldav/accounts/${accountId}/calendars?refresh=true`);
|
||||||
|
await loadCalDAVAccounts(container);
|
||||||
|
window.oikos?.showToast(t('settings.calendarsRefreshed'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind delete buttons
|
||||||
|
listEl.querySelectorAll('[data-caldav-delete]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const accountId = parseInt(btn.dataset.caldavDelete, 10);
|
||||||
|
if (!await confirmModal(t('settings.deleteAccountConfirm'), { danger: true })) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/calendar/caldav/accounts/${accountId}`);
|
||||||
|
window.oikos?.showToast(t('settings.caldavAccountDeleted'), 'success');
|
||||||
|
await loadCalDAVAccounts(container);
|
||||||
|
} catch (err) {
|
||||||
|
window.oikos?.showToast(err.message, 'danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load CalDAV accounts:', err);
|
||||||
|
window.oikos?.showToast(t('settings.caldavConnectionFailed'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load CalDAV accounts on page load
|
||||||
|
if (user?.role === 'admin') {
|
||||||
|
loadCalDAVAccounts(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalDAV add account button
|
||||||
|
const caldavAddBtn = container.querySelector('#caldav-add-account-btn');
|
||||||
|
if (caldavAddBtn) {
|
||||||
|
caldavAddBtn.addEventListener('click', () => {
|
||||||
|
openModal({
|
||||||
|
title: t('settings.caldavAddAccount'),
|
||||||
|
size: 'sm',
|
||||||
|
content: `
|
||||||
|
<form id="caldav-add-form" novalidate autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="caldav-name">${t('settings.caldavNameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||||
|
<input class="form-input" type="text" id="caldav-name" required
|
||||||
|
placeholder="${t('settings.caldavNamePlaceholder')}" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="caldav-url">${t('settings.caldavUrlLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||||
|
<input class="form-input" type="url" id="caldav-url" required
|
||||||
|
placeholder="${t('settings.caldavUrlPlaceholder')}" />
|
||||||
|
<small class="form-hint">${t('settings.caldavUrlHint')}</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="caldav-username">${t('settings.caldavUsernameLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||||
|
<input class="form-input" type="text" id="caldav-username" required autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="caldav-password">${t('settings.caldavPasswordLabel')}<span class="required-marker" aria-hidden="true"> *</span></label>
|
||||||
|
<input class="form-input" type="password" id="caldav-password" required autocomplete="current-password" />
|
||||||
|
<small class="form-hint">${t('settings.caldavPasswordHint')}</small>
|
||||||
|
</div>
|
||||||
|
<div id="caldav-add-error" class="form-error" hidden></div>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
onSave: async (panel) => {
|
||||||
|
const form = panel.querySelector('#caldav-add-form');
|
||||||
|
const errorEl = panel.querySelector('#caldav-add-error');
|
||||||
|
errorEl.hidden = true;
|
||||||
|
|
||||||
|
const name = panel.querySelector('#caldav-name').value.trim();
|
||||||
|
const caldavUrl = panel.querySelector('#caldav-url').value.trim();
|
||||||
|
const username = panel.querySelector('#caldav-username').value.trim();
|
||||||
|
const password = panel.querySelector('#caldav-password').value;
|
||||||
|
|
||||||
|
if (!name || !caldavUrl || !username || !password) {
|
||||||
|
showError(errorEl, t('common.requiredFields'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/calendar/caldav/accounts', {
|
||||||
|
name,
|
||||||
|
caldavUrl,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
closeModal({ force: true });
|
||||||
|
window.oikos?.showToast(t('settings.caldavAccountAdded'), 'success');
|
||||||
|
await loadCalDAVAccounts(container);
|
||||||
|
} catch (err) {
|
||||||
|
showError(errorEl, err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mitglied hinzufügen (Admin)
|
// Mitglied hinzufügen (Admin)
|
||||||
const addMemberBtn = container.querySelector('#add-member-btn');
|
const addMemberBtn = container.querySelector('#add-member-btn');
|
||||||
if (addMemberBtn) {
|
if (addMemberBtn) {
|
||||||
|
|||||||
@@ -772,3 +772,122 @@
|
|||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
CalDAV Components
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
|
||||||
|
.settings-card-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-empty-state {
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-account-item {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-account-header h4 {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-account-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendars-details {
|
||||||
|
margin: var(--space-3) 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendars-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendars-summary:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendars-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
min-height: var(--target-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendar-item:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendar-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendar-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-calendar-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caldav-account-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|||||||
+166
-1
@@ -914,6 +914,167 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date);
|
CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date);
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 29,
|
||||||
|
description: 'Generic CalDAV multi-account support',
|
||||||
|
up: (db) => {
|
||||||
|
// Create caldav_accounts table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE caldav_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
caldav_url TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
last_sync TEXT,
|
||||||
|
UNIQUE(caldav_url, username)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create caldav_calendar_selection table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE caldav_calendar_selection (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
calendar_url TEXT NOT NULL,
|
||||||
|
calendar_name TEXT NOT NULL,
|
||||||
|
calendar_color TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES caldav_accounts(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(account_id, calendar_url)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index for performance
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX idx_caldav_selection_enabled
|
||||||
|
ON caldav_calendar_selection(account_id, enabled)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Update external_calendars to allow 'caldav' source
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE external_calendars_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL CHECK(source IN ('google', 'apple', 'caldav')),
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
UNIQUE(source, external_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO external_calendars_new (id, source, external_id, name, color)
|
||||||
|
SELECT id, source, external_id, name, color
|
||||||
|
FROM external_calendars
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`DROP TABLE external_calendars`);
|
||||||
|
db.exec(`ALTER TABLE external_calendars_new RENAME TO external_calendars`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_ext_cal_source ON external_calendars(source, external_id)`);
|
||||||
|
|
||||||
|
// Migrate existing Apple data
|
||||||
|
const appleUrl = db.prepare("SELECT value FROM sync_config WHERE key='apple_caldav_url'").get()?.value;
|
||||||
|
const appleUser = db.prepare("SELECT value FROM sync_config WHERE key='apple_username'").get()?.value;
|
||||||
|
const applePwd = db.prepare("SELECT value FROM sync_config WHERE key='apple_app_password'").get()?.value;
|
||||||
|
const appleLastSync = db.prepare("SELECT value FROM sync_config WHERE key='apple_last_sync'").get()?.value;
|
||||||
|
|
||||||
|
if (appleUrl && appleUser && applePwd) {
|
||||||
|
// Insert migrated Apple account
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO caldav_accounts (name, caldav_url, username, password, last_sync)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run('Apple Calendar (migriert)', appleUrl, appleUser, applePwd, appleLastSync);
|
||||||
|
|
||||||
|
const accountId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Migrate Apple calendars from external_calendars
|
||||||
|
const appleCalendars = db.prepare(`
|
||||||
|
SELECT external_id, name, color FROM external_calendars WHERE source='apple'
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const cal of appleCalendars) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_calendar_selection
|
||||||
|
(account_id, calendar_url, calendar_name, calendar_color, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, 1)
|
||||||
|
`).run(accountId, cal.external_id, cal.name, cal.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update external_calendars source
|
||||||
|
db.prepare(`UPDATE external_calendars SET source='caldav' WHERE source='apple'`).run();
|
||||||
|
|
||||||
|
// Update calendar_events external_source
|
||||||
|
db.prepare(`UPDATE calendar_events SET external_source='caldav' WHERE external_source='apple'`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add caldav to external_source CHECK constraint by recreating table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE calendar_events_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_datetime TEXT NOT NULL,
|
||||||
|
end_datetime TEXT,
|
||||||
|
all_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
location TEXT,
|
||||||
|
color TEXT NOT NULL DEFAULT '#007AFF',
|
||||||
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
external_calendar_id TEXT,
|
||||||
|
external_source TEXT NOT NULL DEFAULT 'local'
|
||||||
|
CHECK(external_source IN ('local', 'google', 'apple', 'ics', 'caldav')),
|
||||||
|
recurrence_rule TEXT,
|
||||||
|
subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE,
|
||||||
|
user_modified INTEGER NOT NULL DEFAULT 0,
|
||||||
|
calendar_ref_id INTEGER REFERENCES external_calendars(id) ON DELETE SET NULL,
|
||||||
|
icon TEXT NOT NULL DEFAULT 'calendar',
|
||||||
|
attachment_name TEXT,
|
||||||
|
attachment_mime TEXT,
|
||||||
|
attachment_size INTEGER,
|
||||||
|
attachment_data TEXT,
|
||||||
|
target_caldav_account_id INTEGER,
|
||||||
|
target_caldav_calendar_url TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
INSERT INTO calendar_events_new
|
||||||
|
(id, title, description, start_datetime, end_datetime, all_day, location, color,
|
||||||
|
assigned_to, created_by, external_calendar_id, external_source, recurrence_rule,
|
||||||
|
subscription_id, user_modified, calendar_ref_id, icon,
|
||||||
|
attachment_name, attachment_mime, attachment_size, attachment_data,
|
||||||
|
created_at, updated_at)
|
||||||
|
SELECT id, title, description, start_datetime, end_datetime, all_day, location, color,
|
||||||
|
assigned_to, created_by, external_calendar_id, external_source, recurrence_rule,
|
||||||
|
subscription_id, user_modified, calendar_ref_id, icon,
|
||||||
|
attachment_name, attachment_mime, attachment_size, attachment_data,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM calendar_events
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`DROP TRIGGER IF EXISTS trg_calendar_events_updated_at`);
|
||||||
|
db.exec(`DROP TABLE calendar_events`);
|
||||||
|
db.exec(`ALTER TABLE calendar_events_new RENAME TO calendar_events`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TRIGGER trg_calendar_events_updated_at
|
||||||
|
AFTER UPDATE ON calendar_events FOR EACH ROW
|
||||||
|
BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id)`);
|
||||||
|
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -938,7 +1099,11 @@ function migrate() {
|
|||||||
if (pending.length === 0) return;
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
const runMigration = db.transaction((migration) => {
|
const runMigration = db.transaction((migration) => {
|
||||||
db.exec(migration.up);
|
if (typeof migration.up === 'function') {
|
||||||
|
migration.up(db);
|
||||||
|
} else {
|
||||||
|
db.exec(migration.up);
|
||||||
|
}
|
||||||
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
|
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
|
||||||
.run(migration.version, migration.description);
|
.run(migration.version, migration.description);
|
||||||
log.info(`Migration ${migration.version} applied: ${migration.description}`);
|
log.info(`Migration ${migration.version} applied: ${migration.description}`);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as db from '../db.js';
|
|||||||
import * as googleCalendar from '../services/google-calendar.js';
|
import * as googleCalendar from '../services/google-calendar.js';
|
||||||
import * as appleCalendar from '../services/apple-calendar.js';
|
import * as appleCalendar from '../services/apple-calendar.js';
|
||||||
import * as icsSubscription from '../services/ics-subscription.js';
|
import * as icsSubscription from '../services/ics-subscription.js';
|
||||||
|
import * as caldavSync from '../services/caldav-sync.js';
|
||||||
import { requireAdmin } from '../auth.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 { str, color, datetime, rrule, collectErrors, MAX_TITLE, MAX_TEXT, DATE_RE, DATETIME_RE } from '../middleware/validate.js';
|
||||||
import { nextOccurrence } from '../services/recurrence.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;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Generic CalDAV Sync
|
||||||
|
* Zweck: Multi-Account CalDAV synchronization with calendar selection
|
||||||
|
* Abhängigkeiten: tsdav, server/db.js, server/services/ics-parser.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
const log = createLogger('CalDAV');
|
||||||
|
|
||||||
|
import * as db from '../db.js';
|
||||||
|
|
||||||
|
// Reused functions from apple-calendar.js
|
||||||
|
import {
|
||||||
|
parseICS,
|
||||||
|
formatICSDate,
|
||||||
|
tzLocalToUTC,
|
||||||
|
applyDuration
|
||||||
|
} from './ics-parser.js';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Helper Functions
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeCalColor(c) {
|
||||||
|
if (!c) return null;
|
||||||
|
if (/^#[0-9a-fA-F]{8}$/.test(c)) return c.slice(0, 7); // strip alpha
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertExternalCalendar(source, externalId, name, color) {
|
||||||
|
const row = db.get().prepare(`
|
||||||
|
INSERT INTO external_calendars (source, external_id, name, color)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(source, external_id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
color = excluded.color
|
||||||
|
RETURNING id
|
||||||
|
`).get(source, externalId, name, color);
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Credentials Helpers
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function getAccountById(accountId) {
|
||||||
|
return db.get().prepare('SELECT * FROM caldav_accounts WHERE id = ?').get(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllAccounts() {
|
||||||
|
return db.get().prepare('SELECT * FROM caldav_accounts').all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Connection Testing
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async function testConnection(caldavUrl, username, password) {
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
throw new Error('Connected, but no calendars found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, calendars };
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Connection test failed:', err.message);
|
||||||
|
throw new Error(`CalDAV connection failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Account Management
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
async function addAccount(name, caldavUrl, username, password) {
|
||||||
|
// Validate inputs
|
||||||
|
if (!name || !caldavUrl || !username || !password) {
|
||||||
|
throw new Error('All fields required: name, caldavUrl, username, password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection first
|
||||||
|
const { calendars } = await testConnection(caldavUrl, username, password);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = db.get().prepare(
|
||||||
|
'SELECT id FROM caldav_accounts WHERE caldav_url = ? AND username = ?'
|
||||||
|
).get(caldavUrl, username);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Account with this URL and username already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if DB_ENCRYPTION_KEY not set
|
||||||
|
if (!process.env.DB_ENCRYPTION_KEY) {
|
||||||
|
log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CalDAV credentials will be stored unencrypted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert account
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO caldav_accounts (name, caldav_url, username, password)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(name, caldavUrl, username, password);
|
||||||
|
|
||||||
|
const accountId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Insert calendar selections (all enabled by default)
|
||||||
|
const calendarData = [];
|
||||||
|
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);
|
||||||
|
|
||||||
|
calendarData.push({ url: cal.url, name: calName, color: calColor, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Added CalDAV account "${name}" with ${calendars.length} calendars.`);
|
||||||
|
|
||||||
|
return { accountId, calendars: calendarData };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAccounts() {
|
||||||
|
const accounts = db.get().prepare(`
|
||||||
|
SELECT id, name, caldav_url, username, created_at, last_sync
|
||||||
|
FROM caldav_accounts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
// Do NOT return password (security)
|
||||||
|
return accounts.map(acc => ({
|
||||||
|
id: acc.id,
|
||||||
|
name: acc.name,
|
||||||
|
caldavUrl: acc.caldav_url,
|
||||||
|
username: acc.username,
|
||||||
|
createdAt: acc.created_at,
|
||||||
|
lastSync: acc.last_sync,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAccount(accountId, { name, caldavUrl, username, password }) {
|
||||||
|
const account = getAccountById(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If credentials changed, test connection
|
||||||
|
const credentialsChanged =
|
||||||
|
(caldavUrl && caldavUrl !== account.caldav_url) ||
|
||||||
|
(username && username !== account.username) ||
|
||||||
|
(password && password !== account.password);
|
||||||
|
|
||||||
|
if (credentialsChanged) {
|
||||||
|
const testUrl = caldavUrl || account.caldav_url;
|
||||||
|
const testUser = username || account.username;
|
||||||
|
const testPwd = password || account.password;
|
||||||
|
|
||||||
|
const { calendars } = await testConnection(testUrl, testUser, testPwd);
|
||||||
|
|
||||||
|
// If credentials changed, refresh calendar list
|
||||||
|
if (calendars) {
|
||||||
|
// Delete old selections
|
||||||
|
db.get().prepare('DELETE FROM caldav_calendar_selection WHERE account_id = ?').run(accountId);
|
||||||
|
|
||||||
|
// Insert new selections
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update account
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (name) { updates.push('name = ?'); values.push(name); }
|
||||||
|
if (caldavUrl) { updates.push('caldav_url = ?'); values.push(caldavUrl); }
|
||||||
|
if (username) { updates.push('username = ?'); values.push(username); }
|
||||||
|
if (password) { updates.push('password = ?'); values.push(password); }
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new Error('No fields to update.');
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(accountId);
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE caldav_accounts SET ${updates.join(', ')} WHERE id = ?
|
||||||
|
`).run(...values);
|
||||||
|
|
||||||
|
log.info(`Updated CalDAV account ${accountId}.`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAccount(accountId) {
|
||||||
|
const account = getAccountById(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASCADE will delete caldav_calendar_selection entries
|
||||||
|
db.get().prepare('DELETE FROM caldav_accounts WHERE id = ?').run(accountId);
|
||||||
|
|
||||||
|
// Events with calendar_ref_id to deleted account remain (orphaned but visible)
|
||||||
|
|
||||||
|
log.info(`Deleted CalDAV account ${accountId} ("${account.name}").`);
|
||||||
|
|
||||||
|
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
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
addAccount,
|
||||||
|
listAccounts,
|
||||||
|
updateAccount,
|
||||||
|
deleteAccount,
|
||||||
|
getCalendars,
|
||||||
|
updateCalendarSelection,
|
||||||
|
sync,
|
||||||
|
getStatus
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Test: CalDAV Multi-Account Sync
|
||||||
|
* Purpose: Verify CalDAV multi-account functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
const TEST_DB = ':memory:';
|
||||||
|
|
||||||
|
describe('CalDAV Multi-Account Sync', () => {
|
||||||
|
let db;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
// Create in-memory DB
|
||||||
|
db = new DatabaseSync(TEST_DB);
|
||||||
|
|
||||||
|
// Create tables (simplified schema for testing)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE caldav_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
caldav_url TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_sync TEXT,
|
||||||
|
UNIQUE(caldav_url, username)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE caldav_calendar_selection (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
calendar_url TEXT NOT NULL,
|
||||||
|
calendar_name TEXT NOT NULL,
|
||||||
|
calendar_color TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES caldav_accounts(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(account_id, calendar_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE calendar_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
external_calendar_id TEXT,
|
||||||
|
external_source TEXT,
|
||||||
|
target_caldav_account_id INTEGER,
|
||||||
|
target_caldav_calendar_url TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE external_calendars (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
external_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
UNIQUE(source, external_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (username) VALUES ('testuser');
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create caldav_accounts table with correct schema', () => {
|
||||||
|
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='caldav_accounts'").get();
|
||||||
|
assert.ok(result, 'caldav_accounts table should exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create caldav_calendar_selection table with FK', () => {
|
||||||
|
const result = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='caldav_calendar_selection'").get();
|
||||||
|
assert.ok(result, 'caldav_calendar_selection table should exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have target columns in calendar_events', () => {
|
||||||
|
const cols = db.prepare("PRAGMA table_info(calendar_events)").all();
|
||||||
|
const colNames = cols.map(c => c.name);
|
||||||
|
|
||||||
|
assert.ok(colNames.includes('target_caldav_account_id'), 'Should have target_caldav_account_id column');
|
||||||
|
assert.ok(colNames.includes('target_caldav_calendar_url'), 'Should have target_caldav_calendar_url column');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert account and enforce UNIQUE constraint', () => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_accounts (name, caldav_url, username, password)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run('Test Account', 'https://caldav.example.com', 'user', 'pass');
|
||||||
|
|
||||||
|
const account = db.prepare('SELECT * FROM caldav_accounts WHERE name = ?').get('Test Account');
|
||||||
|
assert.ok(account, 'Account should be inserted');
|
||||||
|
assert.strictEqual(account.caldav_url, 'https://caldav.example.com');
|
||||||
|
|
||||||
|
// Duplicate should fail
|
||||||
|
assert.throws(() => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_accounts (name, caldav_url, username, password)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run('Duplicate', 'https://caldav.example.com', 'user', 'pass');
|
||||||
|
}, 'UNIQUE constraint should prevent duplicates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert calendar selection and link to account', () => {
|
||||||
|
const accountId = db.prepare('SELECT id FROM caldav_accounts WHERE name = ?').get('Test Account').id;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_calendar_selection (account_id, calendar_url, calendar_name, enabled)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(accountId, 'https://cal.example.com/cal1', 'Private', 1);
|
||||||
|
|
||||||
|
const calendar = db.prepare('SELECT * FROM caldav_calendar_selection WHERE account_id = ?').get(accountId);
|
||||||
|
assert.ok(calendar, 'Calendar should be inserted');
|
||||||
|
assert.strictEqual(calendar.calendar_name, 'Private');
|
||||||
|
assert.strictEqual(calendar.enabled, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should CASCADE delete calendar_selection when account deleted', () => {
|
||||||
|
const accountId = db.prepare('SELECT id FROM caldav_accounts WHERE name = ?').get('Test Account').id;
|
||||||
|
|
||||||
|
// Delete account
|
||||||
|
db.prepare('DELETE FROM caldav_accounts WHERE id = ?').run(accountId);
|
||||||
|
|
||||||
|
// Calendar selection should be deleted
|
||||||
|
const remaining = db.prepare('SELECT * FROM caldav_calendar_selection WHERE account_id = ?').get(accountId);
|
||||||
|
assert.strictEqual(remaining, undefined, 'Calendar selection should be deleted via CASCADE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle enabled/disabled calendar selection', () => {
|
||||||
|
// Insert new account
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_accounts (name, caldav_url, username, password)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run('Account 2', 'https://caldav2.example.com', 'user2', 'pass2');
|
||||||
|
|
||||||
|
const accountId = db.prepare('SELECT id FROM caldav_accounts WHERE name = ?').get('Account 2').id;
|
||||||
|
|
||||||
|
// Insert calendars
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO caldav_calendar_selection (account_id, calendar_url, calendar_name, enabled)
|
||||||
|
VALUES (?, ?, ?, ?), (?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
accountId, 'https://cal.example.com/cal1', 'Private', 1,
|
||||||
|
accountId, 'https://cal.example.com/cal2', 'Work', 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Query only enabled
|
||||||
|
const enabled = db.prepare('SELECT * FROM caldav_calendar_selection WHERE account_id = ? AND enabled = 1').all(accountId);
|
||||||
|
assert.strictEqual(enabled.length, 1, 'Should have 1 enabled calendar');
|
||||||
|
assert.strictEqual(enabled[0].calendar_name, 'Private');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user