Import an event from an .ics file #2

Open
opened 2026-10-20 13:29:24 -04:00 by connor · 2 comments
Owner

Request

Events sometimes arrive by email as an attached .ics file. There should be a way to upload one in Runway and have its event added to one of my calendars.

What exists

  • runway-core::ical already parses whole VCALENDAR objects, including VTIMEZONE, and the golden corpus covers files from Outlook, Thunderbird, Google and DAVx5.
  • POST /api/events creates one event from a VEvent, and mints its UID on the server so a client-chosen UID cannot overwrite an unrelated event.
  • There is no upload path in the web client or the API.

To decide

  • Where the upload starts: a button in the header or sidebar, a drop target on the calendar, or both.
  • Whether the event is shown in the form first so the calendar and details can be checked before saving, or written straight away.
  • UID handling. An invitation's UID identifies the event to the organizer, so importing the same file twice, or a later update to the same invitation, should replace the existing event rather than add a second one. That conflicts with the create endpoint minting a fresh UID.
  • Files with more than one event, a recurring event with overrides, or a METHOD:CANCEL / METHOD:REQUEST from an invitation (iTIP). What happens to METHOD, ORGANIZER and ATTENDEE on import.
  • Files that reference a TZID with no VTIMEZONE, or use Windows zone names (the Outlook golden file covers the latter).
  • A size limit and a clear error for a file that does not parse.
## Request Events sometimes arrive by email as an attached `.ics` file. There should be a way to upload one in Runway and have its event added to one of my calendars. ## What exists - `runway-core::ical` already parses whole `VCALENDAR` objects, including `VTIMEZONE`, and the golden corpus covers files from Outlook, Thunderbird, Google and DAVx5. - `POST /api/events` creates one event from a `VEvent`, and mints its UID on the server so a client-chosen UID cannot overwrite an unrelated event. - There is no upload path in the web client or the API. ## To decide - Where the upload starts: a button in the header or sidebar, a drop target on the calendar, or both. - Whether the event is shown in the form first so the calendar and details can be checked before saving, or written straight away. - UID handling. An invitation's UID identifies the event to the organizer, so importing the same file twice, or a later update to the same invitation, should replace the existing event rather than add a second one. That conflicts with the create endpoint minting a fresh UID. - Files with more than one event, a recurring event with overrides, or a `METHOD:CANCEL` / `METHOD:REQUEST` from an invitation (iTIP). What happens to `METHOD`, `ORGANIZER` and `ATTENDEE` on import. - Files that reference a `TZID` with no `VTIMEZONE`, or use Windows zone names (the Outlook golden file covers the latter). - A size limit and a clear error for a file that does not parse.
Author
Owner

Plan

Server:

  • POST /api/events/import takes the raw .ics text, a target calendar, and preview. Parsing stays on the server; the ical feature is not in the wasm build.
  • Events are grouped by UID; a series and its overrides are one group and one resource.
  • Preview returns, per group: title, start, whether it repeats, and any existing event with that UID in any of the user's calendars (href, calendar, ETag, SEQUENCE).
  • Import keeps the file's UID. A UID already present is replaced in place in its own calendar, with the ETag from the preview as the precondition. A file with a lower SEQUENCE than the stored event needs an explicit confirmation.
  • METHOD is removed before writing. None/PUBLISH/REQUEST import. CANCEL offers to delete the matching event, or reports there is nothing to cancel. REPLY/COUNTER are refused.
  • ORGANIZER and ATTENDEE are kept. Check against Baikal that writing an event with attendees sends no mail.
  • Zones that unresolved_zones cannot resolve are listed in the preview and block the import.
  • Body limit 1 MiB; a parse failure returns a readable error.
  • runway-caldav gains a UID lookup: a calendar-query with a UID prop-filter, per calendar.

Web:

  • "Import .ics" in the sidebar beside the calendar list, plus dropping a file anywhere on the calendar.
  • A summary dialog (events, calendar picker, Import or Replace) rather than the edit form, which edits one event.

Tests: Baikal server tests for a plain import, a re-import that replaces, an older SEQUENCE, CANCEL, an unresolvable zone, and several UIDs in one file, drawing on the golden corpus; a browser check in e2e/shoot.mjs.

Out of scope: RSVP/iTIP replies, reading mail, subscribing to .ics URLs (feeds cover that).

## Plan Server: - `POST /api/events/import` takes the raw `.ics` text, a target calendar, and `preview`. Parsing stays on the server; the `ical` feature is not in the wasm build. - Events are grouped by UID; a series and its overrides are one group and one resource. - Preview returns, per group: title, start, whether it repeats, and any existing event with that UID in any of the user's calendars (href, calendar, ETag, SEQUENCE). - Import keeps the file's UID. A UID already present is replaced in place in its own calendar, with the ETag from the preview as the precondition. A file with a lower SEQUENCE than the stored event needs an explicit confirmation. - `METHOD` is removed before writing. None/`PUBLISH`/`REQUEST` import. `CANCEL` offers to delete the matching event, or reports there is nothing to cancel. `REPLY`/`COUNTER` are refused. - `ORGANIZER` and `ATTENDEE` are kept. Check against Baikal that writing an event with attendees sends no mail. - Zones that `unresolved_zones` cannot resolve are listed in the preview and block the import. - Body limit 1 MiB; a parse failure returns a readable error. - `runway-caldav` gains a UID lookup: a `calendar-query` with a UID `prop-filter`, per calendar. Web: - "Import .ics" in the sidebar beside the calendar list, plus dropping a file anywhere on the calendar. - A summary dialog (events, calendar picker, Import or Replace) rather than the edit form, which edits one event. Tests: Baikal server tests for a plain import, a re-import that replaces, an older SEQUENCE, CANCEL, an unresolvable zone, and several UIDs in one file, drawing on the golden corpus; a browser check in `e2e/shoot.mjs`. Out of scope: RSVP/iTIP replies, reading mail, subscribing to `.ics` URLs (feeds cover that).
Author
Owner

Implemented in the working tree (not committed)

What was done:

  • runway-core::import::read turns a file into an intent (add or cancel) and one VCalendar per UID, carrying only the VTIMEZONEs each one references. METHOD is read off; REPLY/COUNTER/DECLINECOUNTER and ADD/REFRESH are refused.
  • runway-caldav gains objects_with_uid: a calendar-query with a UID prop-filter, re-checked for an exact match because text-match is a substring match.
  • POST /api/events/import (1 MiB body limit). The preview reports each item's start on the viewer's clock, the existing event with the same UID in any calendar (href, calendar, ETag, SEQUENCE), and unresolvable zones. The import checks every item before writing anything, then creates, replaces with If-Match on the ETag the preview showed, or cancels. A cancellation naming occurrences by RECURRENCE-ID removes only those, through series::apply_delete.
  • Web: "Import an .ics file…" under the calendar list, plus a file dropped anywhere on the shell. The summary dialog lists each event with what will happen to it, a calendar picker when there are new events, a "replace the newer version anyway" checkbox when the file's SEQUENCE is lower, and one button.
  • Baikal's invite_from is empty on the live server, so its iMIP plugin is off and importing an event with attendees sends no mail.

Measured:

  • fmt, clippy -D warnings, cargo doc (no warnings), cargo test --workspace, and the wasm check all clean.
  • Baikal: live 12/12 (new: an_event_can_be_found_by_its_uid), events 20/20, auth 25/25, calendars 17/17, new import suite 14/14 (preview writes nothing, UID and attendees kept, re-import needs confirmation, update replaces in the event's own calendar, stale ETag refused, SEQUENCE reported, whole and single-occurrence cancellation, nothing to cancel, unknown zone blocks, several UIDs, REPLY refused, the Outlook golden file with Windows zones, unparseable file).
  • Unit tests: 9 in runway-core/tests/import.rs, 8 in runway-web/src/import.rs.
  • e2e/shoot.mjs: pick an invitation ("1 added."), pick its update ("1 replaced."), drop its cancellation ("1 cancelled.", event gone), and an unknown zone disables Import with a reason. No console errors.

Known limitations:

  • Replacing an event takes the file as authoritative, so anything changed locally on that event since the last import (an alarm added in Runway, say) is overwritten.
  • The checks all run before any write, but a CalDAV failure partway through a multi-event file leaves the earlier events written.
  • Read-only CalDAV calendars are still offered in the picker; Runway does not read privileges.
  • The UID lookup is one REPORT per calendar per UID, which is fine for an invitation and slow for a file with hundreds of events.

Corrected along the way:

  • The zone list was deduplicated with dedup_by alone, which only drops adjacent duplicates; a zone defined in two VCALENDARs would have been written twice. Sorted first now.
  • An existing event with no ETag passed the confirmation check (None == None) and would then have been written unconditionally. Such an event now cannot be confirmed.
  • The preview validated the target calendar, which failed when the dialog opened before the calendar list had loaded. Only the import validates it now.
## Implemented in the working tree (not committed) What was done: - `runway-core::import::read` turns a file into an intent (add or cancel) and one `VCalendar` per UID, carrying only the `VTIMEZONE`s each one references. `METHOD` is read off; `REPLY`/`COUNTER`/`DECLINECOUNTER` and `ADD`/`REFRESH` are refused. - `runway-caldav` gains `objects_with_uid`: a `calendar-query` with a UID `prop-filter`, re-checked for an exact match because `text-match` is a substring match. - `POST /api/events/import` (1 MiB body limit). The preview reports each item's start on the viewer's clock, the existing event with the same UID in any calendar (href, calendar, ETag, SEQUENCE), and unresolvable zones. The import checks every item before writing anything, then creates, replaces with `If-Match` on the ETag the preview showed, or cancels. A cancellation naming occurrences by `RECURRENCE-ID` removes only those, through `series::apply_delete`. - Web: "Import an .ics file…" under the calendar list, plus a file dropped anywhere on the shell. The summary dialog lists each event with what will happen to it, a calendar picker when there are new events, a "replace the newer version anyway" checkbox when the file's SEQUENCE is lower, and one button. - Baikal's `invite_from` is empty on the live server, so its iMIP plugin is off and importing an event with attendees sends no mail. Measured: - fmt, clippy `-D warnings`, `cargo doc` (no warnings), `cargo test --workspace`, and the wasm check all clean. - Baikal: live 12/12 (new: `an_event_can_be_found_by_its_uid`), events 20/20, auth 25/25, calendars 17/17, new `import` suite 14/14 (preview writes nothing, UID and attendees kept, re-import needs confirmation, update replaces in the event's own calendar, stale ETag refused, SEQUENCE reported, whole and single-occurrence cancellation, nothing to cancel, unknown zone blocks, several UIDs, REPLY refused, the Outlook golden file with Windows zones, unparseable file). - Unit tests: 9 in `runway-core/tests/import.rs`, 8 in `runway-web/src/import.rs`. - `e2e/shoot.mjs`: pick an invitation ("1 added."), pick its update ("1 replaced."), drop its cancellation ("1 cancelled.", event gone), and an unknown zone disables Import with a reason. No console errors. Known limitations: - Replacing an event takes the file as authoritative, so anything changed locally on that event since the last import (an alarm added in Runway, say) is overwritten. - The checks all run before any write, but a CalDAV failure partway through a multi-event file leaves the earlier events written. - Read-only CalDAV calendars are still offered in the picker; Runway does not read privileges. - The UID lookup is one REPORT per calendar per UID, which is fine for an invitation and slow for a file with hundreds of events. Corrected along the way: - The zone list was deduplicated with `dedup_by` alone, which only drops adjacent duplicates; a zone defined in two `VCALENDAR`s would have been written twice. Sorted first now. - An existing event with no ETag passed the confirmation check (`None == None`) and would then have been written unconditionally. Such an event now cannot be confirmed. - The preview validated the target calendar, which failed when the dialog opened before the calendar list had loaded. Only the import validates it now.
connor referenced this issue from a commit 2026-10-20 16:14:02 -04:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: connor/runway#2