[Feature][Calendar] Import .ics calendars — one-shot file upload (Microsoft / Google / Apple) #802

Closed
opened 2026-07-22 22:46:51 +00:00 by spikerj · 2 comments
Owner

Part of the unified scheduling epic #781.

Users live in Outlook/Microsoft 365, Google Calendar, and Apple iCloud. All three export iCalendar (.ics / RFC 5545), and we already ship Ical.Net (5.2.3) driving RecurrenceExpansionService. This ticket adds the first read path: parse an uploaded .ics and import its events for the current user.

Chosen approach (see #781 discussion): universal .ics first — no per-provider OAuth, no verification lead time; covers all three ecosystems (Apple has no REST calendar API at all, so .ics/CalDAV is the only path there anyway).

Scope

  • Model: add ExternalUid (VEVENT UID) + ImportSource fields to CalendarEvent (nullable, backward-compatible). Idempotency key = (CreatedBy, ExternalUid).
  • Parser (SpikerSoft.Business/Domain/Calendar/Import/IcsImportParser, pure): VEVENT → CalendarEvent. SUMMARY→Title, DTSTART/DTEND→UTC, RRULE→RecurrenceRule (verbatim string), EXDATE→ExceptionDates, UID→ExternalUid, all-day handling, TZID→UTC (incl. Outlook Windows-zone-ID fallback), dedup by UID.
  • Import service (CalendarImportService): upsert by (CreatedBy, ExternalUid) — re-import updates, never duplicates; never touches another user's rows or user-authored events.
  • API: POST api/calendar/import (IFormFile, size-limited) via ImportCalendarCommand (MediatR + [CacheInvalidation]); returns {created, updated, skipped, total}.

Known lossiness (documented, not a bug)

  • RECURRENCE-ID per-instance overrides (a single moved/edited occurrence) are skipped and counted — the internal model stores a series as one document with EXDATE for deletions but has no per-instance override slot. Follow-up if needed.

Tests (tight)

Parser matrix (single event, TZID→UTC, all-day, RRULE preserved, EXDATE, RECURRENCE-ID skip, duplicate-UID dedup, malformed→reject, missing SUMMARY fallback, Windows TZID); import-service upsert (insert / update-same-UID / cross-user isolation / idempotent re-import); command-handler + endpoint.

Ships together with the subscription-feed ticket in one WIP PR.

Part of the unified scheduling epic #781. Users live in Outlook/Microsoft 365, Google Calendar, and Apple iCloud. All three export **iCalendar (.ics / RFC 5545)**, and we already ship Ical.Net (5.2.3) driving `RecurrenceExpansionService`. This ticket adds the first **read** path: parse an uploaded `.ics` and import its events for the current user. Chosen approach (see #781 discussion): universal `.ics` first — no per-provider OAuth, no verification lead time; covers all three ecosystems (Apple has no REST calendar API at all, so `.ics`/CalDAV is the only path there anyway). ## Scope - **Model:** add `ExternalUid` (VEVENT `UID`) + `ImportSource` fields to `CalendarEvent` (nullable, backward-compatible). Idempotency key = `(CreatedBy, ExternalUid)`. - **Parser** (`SpikerSoft.Business/Domain/Calendar/Import/IcsImportParser`, pure): VEVENT → `CalendarEvent`. `SUMMARY→Title`, `DTSTART/DTEND→UTC`, `RRULE→RecurrenceRule` (verbatim string), `EXDATE→ExceptionDates`, `UID→ExternalUid`, all-day handling, TZID→UTC (incl. Outlook Windows-zone-ID fallback), dedup by UID. - **Import service** (`CalendarImportService`): upsert by `(CreatedBy, ExternalUid)` — re-import updates, never duplicates; never touches another user's rows or user-authored events. - **API:** `POST api/calendar/import` (`IFormFile`, size-limited) via `ImportCalendarCommand` (MediatR + `[CacheInvalidation]`); returns `{created, updated, skipped, total}`. ## Known lossiness (documented, not a bug) - `RECURRENCE-ID` per-instance overrides (a single moved/edited occurrence) are **skipped and counted** — the internal model stores a series as one document with EXDATE for deletions but has no per-instance override slot. Follow-up if needed. ## Tests (tight) Parser matrix (single event, TZID→UTC, all-day, RRULE preserved, EXDATE, RECURRENCE-ID skip, duplicate-UID dedup, malformed→reject, missing SUMMARY fallback, Windows TZID); import-service upsert (insert / update-same-UID / cross-user isolation / idempotent re-import); command-handler + endpoint. **Ships together with the subscription-feed ticket in one WIP PR.**
Author
Owner

Implemented in spikersoft-backend PR #456 (WIP, ships with #803).

Parser + upsert done and tested against real provider-shaped .ics fixtures (Google IANA VTIMEZONE, Outlook Windows zone id, Apple all-day + folding); the recurring case is asserted by expanding through the real RecurrenceExpansionService so RRULE round-trip + EXDATE + tz are validated as one outcome. CalendarEvent gained ExternalUid + ImportSource; endpoint is POST api/calendar/import.

One extra pre-existing limitation surfaced during review, recorded here so it's not a surprise: DST drift — the recurrence engine expands purely in UTC (ToCalDateTime forces UTC), so a weekly event imported from a DST-observing zone drifts an hour across a DST boundary. Not introduced by import; noted as a known constraint alongside the RECURRENCE-ID skip.

Implemented in spikersoft-backend **PR #456** (WIP, ships with #803). Parser + upsert done and tested against real provider-shaped `.ics` fixtures (Google IANA VTIMEZONE, Outlook Windows zone id, Apple all-day + folding); the recurring case is asserted by expanding through the **real `RecurrenceExpansionService`** so RRULE round-trip + EXDATE + tz are validated as one outcome. `CalendarEvent` gained `ExternalUid` + `ImportSource`; endpoint is `POST api/calendar/import`. One extra pre-existing limitation surfaced during review, recorded here so it's not a surprise: **DST drift** — the recurrence engine expands purely in UTC (`ToCalDateTime` forces UTC), so a weekly event imported from a DST-observing zone drifts an hour across a DST boundary. Not introduced by import; noted as a known constraint alongside the `RECURRENCE-ID` skip.
Author
Owner

Resolved in spikersoft-backend PR #456 (merged to master, ee541921). One-shot .ics upload shipped: POST api/calendar/importImportCalendarCommand → shared IcsImportParser + CalendarImportService (upsert by (CreatedBy, ExternalUid)). CalendarEvent gained ExternalUid + ImportSource. Covers Outlook/Google/Apple exports; tested against real provider-shaped fixtures with the recurring case validated through the real RecurrenceExpansionService. Documented out-of-scope follow-ups (RECURRENCE-ID overrides, pre-existing UTC/DST drift) recorded above. Closing.

Resolved in spikersoft-backend PR #456 (merged to `master`, `ee541921`). One-shot `.ics` upload shipped: `POST api/calendar/import` → `ImportCalendarCommand` → shared `IcsImportParser` + `CalendarImportService` (upsert by `(CreatedBy, ExternalUid)`). `CalendarEvent` gained `ExternalUid` + `ImportSource`. Covers Outlook/Google/Apple exports; tested against real provider-shaped fixtures with the recurring case validated through the real `RecurrenceExpansionService`. Documented out-of-scope follow-ups (RECURRENCE-ID overrides, pre-existing UTC/DST drift) recorded above. Closing.
Sign in to join this conversation.