From 43395235e213c11dbc46366823d6c425539ddff4 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Fri, 10 Apr 2026 17:24:22 +0200 Subject: [PATCH] Add design spec for Nextcloud CalDAV calendar sync Co-Authored-By: Claude Sonnet 4.6 --- ...26-04-10-nextcloud-calendar-sync-design.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md diff --git a/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md b/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md new file mode 100644 index 0000000..47ca150 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-nextcloud-calendar-sync-design.md @@ -0,0 +1,139 @@ +# Nextcloud Calendar Sync — Design Spec + +**Date:** 2026-04-10 +**Status:** Approved + +--- + +## Overview + +Add a second calendar sync target (Nextcloud CalDAV) alongside the existing Google Calendar sync. Shared logic is extracted into a common interface and a static helper, keeping both service implementations independent and self-contained. + +--- + +## Architecture + +### Shared interface: `ICalendarService` + +Replaces `IGoogleCalendarService`. Both providers implement this interface: + +```csharp +public interface ICalendarService +{ + Task> GetCalendarsAsync(); + Task SyncShiftsToCalendarAsync(string calendarId, List shifts); +} + +public class CalendarInfo +{ + public string Id { get; set; } + public string Summary { get; set; } +} +``` + +### Shared helper: `CalendarEventBuilder` (static class) + +Extracts pure data-transformation logic currently inside `GoogleCalendarService`: + +- `FormatTransportInfo(TransportConnection)` → string +- `BuildEventContent(Shift, DateTimeOffset start, DateTimeOffset end, TransportConnection?)` → returns a shared `CalendarEventData` record (summary + description) + +No orchestration logic lives here — just pure functions with no side effects. + +### `GoogleCalendarService` + +- Implements `ICalendarService` +- Refactored to use `CalendarEventBuilder` for event content +- All Google client library calls remain here +- `GetCalendarsAsync()` returns `List` mapped from Google's `CalendarListEntry` +- Identification of owned events via `ExtendedProperties.Private["shiftSchedulerEvent"]` unchanged + +### `NextcloudCalendarService` + +- Implements `ICalendarService` +- Uses `HttpClient` with Basic Auth (username + app password from configuration) +- CalDAV operations: + - **List calendars**: `PROPFIND` on `/remote.php/dav/calendars//`, depth 1, parse `displayname` and `resourcetype` + - **Delete existing events in range**: `REPORT` (CalDAV `calendar-query`) to find VEVENTs with `X-SHIFT-SCHEDULER:true` property in the date range, then `DELETE` each by URL + - **Create events**: `PUT` a VEVENT (iCalendar format using the existing `Ical.Net` dependency) to `/.ics`. Each event includes `X-SHIFT-SCHEDULER:true` as a custom property for future identification. +- UIDs are generated deterministically: `shift---` to allow idempotent re-sync if needed +- Retry logic mirrors Google's exponential backoff, extracted from `GoogleCalendarService` into a shared `RetryHelper` static method in `CalendarEventBuilder` + +--- + +## Controller + +`ShiftController` is updated: + +- Both `IGoogleCalendarService` and `INextcloudCalendarService` injected. These are empty marker interfaces that extend `ICalendarService`, registered with their concrete types in DI. This avoids DI ambiguity while keeping the shared interface as the contract: + ```csharp + public interface IGoogleCalendarService : ICalendarService { } + public interface INextcloudCalendarService : ICalendarService { } + ``` +- Old endpoints `GET /api/shift/google_calendars` and `POST /api/shift/sync_to_google_calendar` are **replaced** by: + - `GET /api/shift/calendars?provider=google|nextcloud` → `List` + - `POST /api/shift/sync_to_calendar` → body: `{ Provider, CalendarId, Year, Month }` +- Provider routing via a `switch` on the `provider` string — no factory abstraction + +--- + +## Client UI + +### Buttons + +Two separate sync buttons in `Index.razor`: +- **Sync to Google Calendar** (existing, unchanged label) +- **Sync to Nextcloud** (new) + +Both buttons are disabled while `_isSyncing` is true. + +### State + +New field `_syncProvider` (`"google"` or `"nextcloud"`) tracks which provider triggered the current flow. + +Existing `_availableCalendars`, `_showCalendarSelector`, and the calendar picker modal are reused for both providers. The modal title changes to "Select Google Calendar" or "Select Nextcloud Calendar" based on `_syncProvider`. + +### Client DTO + +`GoogleCalendar` record on the client is renamed to `CalendarInfo` to match the server DTO. + +--- + +## Configuration + +### `appsettings.json` (placeholder — committed to git) + +```json +"Nextcloud": { + "BaseUrl": "https://your-nextcloud-instance.example.com", + "Username": "YOUR_NEXTCLOUD_USERNAME", + "AppPassword": "YOUR_NEXTCLOUD_APP_PASSWORD" +} +``` + +### Production appsettings (not committed, mounted via Docker) + +Contains real values for `BaseUrl`, `Username`, and `AppPassword`. + +Read in `Program.cs` via `IConfiguration`, injected into `NextcloudCalendarService` — same pattern as `Authentication:Google`. + +### `DOCKER_CONFIG.md` + +Add a note that the `Nextcloud` section must be populated in the production `appsettings.json`. + +--- + +## Dependencies + +No new NuGet packages required: +- CalDAV HTTP calls: plain `HttpClient` +- iCalendar VEVENT generation: `Ical.Net` (already in `ShiftScheduler.Services.csproj`) +- XML parsing for CalDAV responses: `System.Xml` (built-in) + +--- + +## Out of scope + +- Two-way sync (read events from calendar back into the scheduler) +- Nextcloud OAuth — app password is sufficient for a personal instance +- Caching of calendar lists