# External Calendar Busy Blocks Subscribe Canvas to a provider's personal calendar (Google Calendar, Outlook, Apple iCloud) via the calendar's secret iCal/ICS URL. Every 15 minutes, the plugin fetches each connected feed and writes the busy times as "Busy" events on the provider's Canvas Admin calendar. ## Problem it solves Providers who keep personal or external commitments in Google Calendar, Outlook, or Apple iCloud end up double-booked when those times are not reflected in Canvas. Without this plugin, someone has to copy each outside commitment into Canvas by hand and keep the two calendars in sync as plans change. This plugin pulls the busy times in automatically every 15 minutes so the Canvas schedule blocks the right slots, while keeping the personal event details private. ## How to install ``` canvas install external_calendar_busy_blocks ``` This plugin declares secrets that must be set before it will function. See the Configuration options below. ## How it works Each provider opens the **Calendar Busy Blocks** application from the Canvas global menu, pastes their personal calendar's secret iCal URL, and clicks Save. Connecting a feed automatically finds or creates that provider's Canvas Admin calendar — no manual calendar setup is required (the 15-minute sync also creates it if it is ever missing). A scheduled task runs every 15 minutes: 1. Fetches each provider's ICS feed (sending `If-None-Match` / `If-Modified-Since` so unchanged feeds return `304 Not Modified` and skip work). 2. Parses the feed, filters to confirmed busy events, expands recurring events within a 90-day window, and converts everything to UTC. 3. Reconciles the provider's live Admin calendar against the parsed feed, matching blocks on their `(starts_at, ends_at)` window. Blocks the feed no longer wants are deleted, blocks it wants that aren't present yet are created (`Event.create` / `Event.delete`). Canvas users see only "Busy" — the original event titles never leave the personal calendar. ### Why it reconciles on the live calendar (KOALA-6372) The plugin used to track each block's Canvas event id in an `ImportedEvent` table and update/delete by that id. Canvas's calendar-event **create** interpreter discards the id supplied on the effect and assigns its own uuid (KOALA-6372), so every stored id was a phantom: updates raised "Event does not exist" and deletes silently no-op'd. The plugin could add blocks but never clean them up, and orphaned "Busy" events piled up until they made providers unbookable. As of **0.4.0** the reconcile no longer trusts any stored id. It reads the live "Busy" events straight off the Admin calendar (with their real uuids), matches them to the feed by start/end time, and deletes by the real uuid. This both stops accruing orphans and cleans up ones already stranded — the next few ticks after upgrading will delete existing orphaned blocks (bounded by `MAX_DELETES_PER_SYNC` per feed per tick). A feed that parses to zero events while blocks still exist is treated as a transient glitch and skips deletions, so an upstream hiccup can't wipe a calendar. The `ImportedEvent` table is now unused and inert; it can be dropped in a later release. Note: cleanup runs whenever a feed's contents change (any `Event.create`/`Event.delete` tick). A feed that returns `304 Not Modified` is skipped, so a completely static feed's historical orphans clear on its next real change rather than immediately. ## Admin: connect feeds on behalf of providers Staff whose IDs are listed in the `ADMIN_STAFF_IDS` secret see an extra **Manage another provider** section when they open **Calendar Busy Blocks**. There they pick any active provider, see whether that provider already has a feed connected, and connect/replace or disconnect the provider's secret iCal URL — so providers never have to paste their own URL. Everyone else sees only their own self-service form; the admin section and its API are denied to non-admins (fail closed). Admin-connected feeds sync exactly like self-service feeds, and the stored URL is never shown back in the UI. ## Configuration | Plugin secret | Default | Notes | |---|---|---| | `LOOKAHEAD_DAYS` | `90` | How far in advance to expand recurring events. | | `MAX_DELETES_PER_SYNC` | `500` | Cap on block deletions emitted per feed per tick. A safety valve: bounds the blast radius of a partial parse and spreads the one-time cleanup of historical orphans across ticks. Anything over the cap clears on the next run. | | `ADMIN_STAFF_IDS` | _(unset)_ | Comma-separated Canvas staff IDs allowed to manage other providers' feeds from the admin section. Unset means no one has admin access (the admin section is hidden). | ## Privacy & security - The secret ICS URL **is** a bearer token. Store it as the provider's personal calendar's *secret* iCal URL, not a shared one. - The plugin imports only event start/end times. Titles, descriptions, and attendees are discarded. - URLs are never logged in full — tokens are redacted. ## Limitations - Tentative events (`STATUS=TENTATIVE`) and transparent events (`TRANSP=TRANSPARENT`) are not imported. - Recurring events use a subset of RFC 5545: `FREQ=DAILY|WEEKLY|MONTHLY|YEARLY` with `INTERVAL`, `BYDAY`, `BYMONTHDAY`, `BYMONTH`, `UNTIL`, `COUNT`, `EXDATE`, `RECURRENCE-ID`. Unsupported rule features (`BYSETPOS`, `BYWEEKNO`, `BYYEARDAY`, a non-default `WKST` other than `MO`, sub-daily `BY*`) cause the VEVENT to be dropped with a warning log. `WKST=MO` (the default the expander assumes) is accepted. - One feed per provider in v1. - Source-side lag dominates: Google and Outlook regenerate their public ICS feeds on a 30–60 min cycle; Canvas-side polling is 15 min. - **Supported hosts are allowlisted to known calendar providers** — Google (`*.google.com`), Outlook/Office 365 (`*.outlook.com`, `*.office365.com`, `*.live.com`), and Apple iCloud (`*.icloud.com`). Self-hosted ICS feeds (Nextcloud, Fastmail, etc.) are not supported in v1. This is a deliberate SSRF mitigation: the cron fetches feed URLs server-side from Canvas's network, and the plugin sandbox does not permit the DNS/IP inspection that would otherwise let us safely allow arbitrary hosts. Note that because the SDK HTTP client does not expose redirect control, the allowlist trusts that these providers do not redirect feed requests to internal addresses.