Import an iCal file into a calendar #178

Closed
opened 2026-09-01 15:38:24 +00:00 by jcoffey-dev · 0 comments
Owner

Closes #173.

What it does

Each calendar's own menu (right-click, or the dots button) gains Import iCAL file…, between Edit and Share…. Pick a .ics and everything in it is filed into that calendar. No global button next to + Event — the issue is right that this isn't frequent enough to earn one. The item is disabled on calendars you only have read rights to.

How

The parsing is the server's, through the same CalendarEvent/parse that emailed invitations already go through (store/calendar.ts parseIcs). An .ics is not a format worth reimplementing in a browser, and Stalwart's reader handles what a hand-rolled one would not. No new capability plumbing: usingFor() already maps CalendarEvent/* to calendars + calendars:parse.

One CalendarEvent/set for the whole file. The round trips are the smaller half of the reason — createEvent calls invalidate() on the way out, and invalidating re-fetches every cached range, so a year of events imported one at a time would re-fetch the calendar a few hundred times.

Nothing is mailed to anyone. sendSchedulingMessages: false — importing is filing something you already have, and scheduling messages would be a surprise to its participants.

The per-event scrubbing that importEvent did inline is now a named forImport() shared by both paths: id, calendarIds, baseEventId, utcStart, utcEnd, isOrigin and method all belong to where the event came from. The file's own uid is kept wherever it has one, so the event stays matchable across calendars; only events arriving without one get a fresh one.

The mock

Its CalendarEvent/parse read the whole file with one regex and returned exactly one event, which is all an invitation ever needed — a multi-event file was untestable against it. It now reads per VEVENT and returns an array when there is more than one. It also invents an organiser and attendee only for events carrying a METHOD: those are scheduling messages, which the invitation fixtures are, while a plain export is not addressed to anyone and shouldn't come out looking like an invitation nobody sent.

Verification

npm run typecheck, npm test (502 pass, 10 new in store/__tests__/ics-import.test.ts) and npm run build all green. The new tests pin the things that follow from a file being many events rather than one: one call not N, both events in a single create map, the calendar they land in, the scrubbing, uid kept vs. invented, sendSchedulingMessages: false, the single-event shape, and the two failure messages.

Driven by hand against dev:mock as well — the OS file picker stubbed out, everything else real:

  • three-event file → toast Imported 3 events, all three on the right days in the month grid
  • one-event file → Imported 1 event, exactly one new copy (singular form correct)
  • a file with no VEVENTs → Could not import this file: it has no events in it

Known behaviour

Importing the same file twice creates second copies rather than matching on uid. That's what I'd expect from an import, and de-duplicating raises a question the issue doesn't ask — whether a re-export should update the events it matches. Easy to add later if you want it.

Not translated yet

Three new strings fall back to English in all nine locales.

Merged 2026-09-01 as coffey-labs/ihasmail@53513e6744

Rebuilt from: git history, session transcript.

Closes #173. ## What it does Each calendar's own menu (right-click, or the dots button) gains **Import iCAL file…**, between *Edit* and *Share…*. Pick a `.ics` and everything in it is filed into that calendar. No global button next to *+ Event* — the issue is right that this isn't frequent enough to earn one. The item is disabled on calendars you only have read rights to. ## How **The parsing is the server's**, through the same `CalendarEvent/parse` that emailed invitations already go through (`store/calendar.ts` `parseIcs`). An `.ics` is not a format worth reimplementing in a browser, and Stalwart's reader handles what a hand-rolled one would not. No new capability plumbing: `usingFor()` already maps `CalendarEvent/*` to `calendars` + `calendars:parse`. **One `CalendarEvent/set` for the whole file.** The round trips are the smaller half of the reason — `createEvent` calls `invalidate()` on the way out, and invalidating re-fetches every cached range, so a year of events imported one at a time would re-fetch the calendar a few hundred times. **Nothing is mailed to anyone.** `sendSchedulingMessages: false` — importing is filing something you already have, and scheduling messages would be a surprise to its participants. The per-event scrubbing that `importEvent` did inline is now a named `forImport()` shared by both paths: `id`, `calendarIds`, `baseEventId`, `utcStart`, `utcEnd`, `isOrigin` and `method` all belong to where the event came from. The file's own `uid` is kept wherever it has one, so the event stays matchable across calendars; only events arriving without one get a fresh one. ## The mock Its `CalendarEvent/parse` read the whole file with one regex and returned exactly one event, which is all an invitation ever needed — a multi-event file was untestable against it. It now reads per `VEVENT` and returns an array when there is more than one. It also invents an organiser and attendee only for events carrying a `METHOD`: those are scheduling messages, which the invitation fixtures are, while a plain export is not addressed to anyone and shouldn't come out looking like an invitation nobody sent. ## Verification `npm run typecheck`, `npm test` (502 pass, 10 new in `store/__tests__/ics-import.test.ts`) and `npm run build` all green. The new tests pin the things that follow from a file being many events rather than one: one call not N, both events in a single `create` map, the calendar they land in, the scrubbing, uid kept vs. invented, `sendSchedulingMessages: false`, the single-event shape, and the two failure messages. Driven by hand against `dev:mock` as well — the OS file picker stubbed out, everything else real: - three-event file → toast **Imported 3 events**, all three on the right days in the month grid - one-event file → **Imported 1 event**, exactly one new copy (singular form correct) - a file with no VEVENTs → **Could not import this file: it has no events in it** ## Known behaviour Importing the same file twice creates second copies rather than matching on `uid`. That's what I'd expect from an import, and de-duplicating raises a question the issue doesn't ask — whether a re-export should update the events it matches. Easy to add later if you want it. ## Not translated yet Three new strings fall back to English in all nine locales. **Merged** 2026-09-01 as coffey-labs/ihasmail@53513e674412 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.