Merge pull request #178 from Coffey-Labs/feat/ical-import

Import an iCal file into a calendar
This commit is contained in:
Coffey Labs
2026-09-01 08:43:37 -07:00
committed by GitHub
5 changed files with 327 additions and 7 deletions
+5 -1
View File
@@ -43,7 +43,7 @@ capability removes its feature rather than breaking the app.
| `urn:ietf:params:jmap:vacationresponse` | Out of office | The Settings section hides |
| `urn:ietf:params:jmap:sieve` | Filters, visual and raw | Filters hides |
| `urn:ietf:params:jmap:contacts` (+`:parse`) | Contacts; vCard import | Contacts hides; import only needs `parse` |
| `urn:ietf:params:jmap:calendars` (+`:parse`) | Calendar; iTIP invitations in mail | Calendar hides; invite cards do not render |
| `urn:ietf:params:jmap:calendars` (+`:parse`) | Calendar; iTIP invitations in mail; iCal import | Calendar hides; invite cards and import need `parse` |
| `urn:ietf:params:jmap:principals` | Directory lookup, sharing pickers | Sharing and directory autocomplete step aside |
| `urn:ietf:params:jmap:principals:availability` | Free/busy when scheduling | Guests show no availability |
| `urn:ietf:params:jmap:quota` | Storage bar under the folder list | The bar is not drawn |
@@ -378,6 +378,10 @@ Right-click your own to rename, recolour, share, stop sharing or delete;
right-click one of someone else's to remove it from your view, which changes
nothing for anybody else.
- **iCal import** through `CalendarEvent/parse` (a file of any number of
events), from the calendar's own menu, into that calendar. The events are
filed rather than scheduled: no invitations go out to anyone named in them.
## Events
Created by clicking an empty slot or dragging across a range; a context menu on
+55 -1
View File
@@ -476,6 +476,60 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
* still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server.
*/
/**
* Enough of an iCalendar reader to stand in for Stalwart's.
*
* It reads per VEVENT rather than across the whole file, because a file is the
* case an emailed invitation never was: an export carries a year of them, and a
* regex over the whole text would find the first DTSTART and call that the
* answer. One event still comes back as a bare object, the shape this returned
* when an invitation was all it had to handle.
*
* The synthetic organiser and attendee only go on events that arrived with a
* METHOD. Those are scheduling messages, which is what the invitation fixtures
* are; a plain export is not addressed to anyone, and inventing participants
* for it would make imported events look like invitations nobody sent.
*/
function calendarEventParse(a: Obj) {
const parsed: Obj = {};
const notParsable: string[] = [];
for (const b of a.blobIds as string[]) {
const blob = blobs.get(b);
if (!blob) { notParsable.push(b); continue; }
const text = blob.data.toString();
const field = (src: string, k: string) => new RegExp(`^${k}[^:\r\n]*:(.*)$`, "m").exec(src)?.[1]?.trim();
const method = field(text, "METHOD");
const bodies = text.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g) ?? [];
const events = bodies.map((body) => {
const g = (k: string) => field(body, k);
const ds = g("DTSTART") ?? "20260101T000000Z";
const de = g("DTEND") ?? ds;
const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`;
const start = new Date(`${toLocal(ds)}Z`);
const end = new Date(`${toLocal(de)}Z`);
return {
"@type": "Event",
uid: g("UID"),
title: g("SUMMARY"),
start: toLocal(ds),
timeZone: "Etc/UTC",
duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`,
method,
locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined,
participants: method
? {
org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } },
me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" },
}
: undefined,
};
});
if (!events.length) { notParsable.push(b); continue; }
parsed[b] = events.length === 1 ? events[0] : events;
}
return { accountId: ACCOUNT, parsed, notParsable };
}
function calendarEventSet(a: Obj) {
const created: Obj = {};
const updated: Obj = {};
@@ -961,7 +1015,7 @@ const handlers: Record<string, Handler> = {
// participants addressed the RFC 8984 way. The mock did neither, which is how
// #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": (a) => calendarEventSet(a),
"CalendarEvent/parse": (a) => { const parsed: Obj = {}; for (const b of a.blobIds as string[]) { const blob = blobs.get(b); if (!blob) continue; const t = blob.data.toString(); const g = (k: string) => new RegExp(`^${k}[^:]*:(.*)$`, "m").exec(t)?.[1]?.trim(); const ds = g("DTSTART") ?? "20260101T000000Z"; const de = g("DTEND") ?? ds; const toLocal = (s: string) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}T${s.slice(9, 11)}:${s.slice(11, 13)}:00`; const start = new Date(`${toLocal(ds)}Z`); const end = new Date(`${toLocal(de)}Z`); parsed[b] = { "@type": "Event", uid: g("UID"), title: g("SUMMARY"), start: toLocal(ds), timeZone: "Etc/UTC", duration: `PT${Math.round((end.getTime() - start.getTime()) / 60000)}M`, method: g("METHOD"), locations: g("LOCATION") ? { l: { name: g("LOCATION") } } : undefined, participants: { org: { name: "Ada Lovelace", calendarAddress: "mailto:[email protected]", roles: { owner: true } }, me: { name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { attendee: true, required: true }, participationStatus: "needs-action" } } }; } return { accountId: ACCOUNT, parsed, notParsable: [] }; },
"CalendarEvent/parse": (a) => calendarEventParse(a),
"ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals),
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useCalendar } from "@/store/calendar";
import type { JmapSession, UploadResponse } from "@/jmap/types";
/**
* Importing a file is not importing an invitation, and the difference is the
* count: an emailed invite carries one event, an export carries a year of them.
* These pin the two things that follow from that -- one round trip rather than
* one per event, and nothing of where the events came from riding along into
* the calendar they land in.
*/
/** What the server hands back for a two-event file. Ids and the JMAP-only
* bookkeeping are there because a real parse includes them, and dropping them
* is the store's job. */
const PARSED = [
{
"@type": "Event", id: "srv1", uid: "[email protected]", title: "Kickoff",
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
calendarIds: { somewhere: true }, baseEventId: "b1", utcStart: "2026-09-02T09:00:00Z",
utcEnd: "2026-09-02T10:00:00Z", isOrigin: true, method: "REQUEST",
},
{
"@type": "Event", id: "srv2", title: "Retro (no uid)",
start: "2026-09-09T09:00:00", duration: "PT30M", timeZone: "Etc/UTC",
},
];
interface SetArgs { create?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
/**
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
* than an array is the single-event shape, which Stalwart also returns.
* @param notCreated refusals to hand back instead of creations.
*/
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown> } = {}) {
const sets: SetArgs[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = body.methodCalls.map(([name, args, id]) => {
if (name === "CalendarEvent/parse") {
const blobIds = args.blobIds as string[];
return [name, { accountId: "a1", parsed: parsed === null ? {} : { [blobIds[0]!]: parsed }, notParsable: [] }, id];
}
if (name === "CalendarEvent/set") {
sets.push({ create: args.create as Record<string, Record<string, unknown>>, sendSchedulingMessages: args.sendSchedulingMessages as boolean });
const keys = Object.keys((args.create ?? {}) as object);
const notCreated = opts.notCreated ?? {};
return [name, {
accountId: "a1", oldState: "1", newState: "2",
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
notCreated,
}, id];
}
return [name, { accountId: "a1", state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return sets;
}
let uploaded: { type?: string; text: string } | null = null;
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.calendars]: {} },
accounts: {}, primaryAccounts: {}, state: "s1",
} as unknown as JmapSession;
useCalendar.setState({ accountId: "a1", available: true, calendars: {}, events: {}, ranges: {} });
uploaded = null;
// XHR, not fetch, so it is stubbed at the client rather than at the network.
// jsdom's Blob has no `text()`, hence the reader.
const readBlob = (b: Blob) => new Promise<string>((resolve) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.readAsText(b);
});
vi.spyOn(client, "upload").mockImplementation(async (_acc, data, opts) => {
uploaded = { type: opts?.type, text: await readBlob(data as Blob) };
return { accountId: "a1", blobId: "blob1", type: "text/calendar", size: 1 } as UploadResponse;
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("importing an .ics file", () => {
it("uploads the file as calendar data", async () => {
server(PARSED);
await useCalendar.getState().importIcs("BEGIN:VCALENDAR\nEND:VCALENDAR\n", "cal1");
expect(uploaded?.type).toBe("text/calendar");
expect(uploaded?.text).toContain("BEGIN:VCALENDAR");
});
it("creates every event in one call, not one call each", async () => {
const sets = server(PARSED);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toBe(2);
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toEqual(["e0", "e1"]);
});
it("files them into the calendar that was picked", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
for (const e of Object.values(sets[0]!.create!)) {
expect(e.calendarIds).toEqual({ cal1: true });
}
});
it("leaves behind everything that belonged to where the events came from", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
const first = sets[0]!.create!.e0!;
for (const gone of ["id", "baseEventId", "utcStart", "utcEnd", "isOrigin", "method"]) {
expect(first, gone).not.toHaveProperty(gone);
}
expect(first.title).toBe("Kickoff");
expect(first.start).toBe("2026-09-02T09:00:00");
});
it("keeps the file's own uid, and invents one only where there is none", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.create!.e0!.uid).toBe("[email protected]");
expect(sets[0]!.create!.e1!.uid).toEqual(expect.any(String));
expect(sets[0]!.create!.e1!.uid).not.toBe("");
});
it("does not mail the participants of an event being filed", async () => {
const sets = server(PARSED);
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.sendSchedulingMessages).toBe(false);
});
it("takes a single event, which is what a one-event file parses to", async () => {
const sets = server(PARSED[0]);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toBe(1);
expect(Object.keys(sets[0]!.create!)).toEqual(["e0"]);
});
it("says a file held no events rather than reporting none imported", async () => {
server(null);
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/no events in it/);
});
it("reports the server's refusal when nothing was accepted", async () => {
server(PARSED, { notCreated: { e0: { type: "invalidProperties", description: "start is required" }, e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/start is required/);
});
it("counts what got in when only some of it did", async () => {
server(PARSED, { notCreated: { e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toBe(1);
});
});
+59 -2
View File
@@ -284,6 +284,8 @@ interface CalendarState {
findByUid(uid: string): Promise<CalendarEvent | null>;
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
/** Import a whole .ics file. Returns how many events it created. */
importIcs(text: string, calendarId: Id): Promise<number>;
applyChanges(types: Set<string>): void;
invalidate(): void;
setDraft(draft: EventDraft | null): void;
@@ -302,6 +304,20 @@ const EVENT_PROPS = [
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
];
/**
* An event as it arrived, minus everything that belonged to where it came from.
*
* `id` and `calendarIds` are the copy's, not this one's; `baseEventId`,
* `utcStart`, `utcEnd` and `isOrigin` are the server's own bookkeeping and are
* recomputed for whatever is created here. `method` is the scheduling verb of
* the message that carried it -- REQUEST, CANCEL -- and an event filed into a
* calendar is no longer a message about anything.
*/
function forImport(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
const { id: _id, calendarIds: _c, baseEventId: _b, utcStart: _us, utcEnd: _ue, isOrigin: _io, method: _m, ...rest } = event as CalendarEvent & { method?: string };
return rest;
}
export const useCalendar = create<CalendarState>((set, get) => ({
accountId: null,
available: false,
@@ -673,8 +689,49 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
async importEvent(event, calendarId) {
const { id: _id, calendarIds: _c, baseEventId: _b, utcStart: _us, utcEnd: _ue, isOrigin: _io, method: _m, ...rest } = event as CalendarEvent & { method?: string };
return get().createEvent(rest, calendarId, false);
return get().createEvent(forImport(event), calendarId, false);
},
/*
* A file, rather than the single event an invitation carries.
*
* The parsing is the server's, the same `CalendarEvent/parse` an emailed
* invitation goes through -- an .ics is not a format worth reimplementing in
* a browser, and the one already in Stalwart handles what a hand-rolled
* parser would not.
*
* Every event goes out in one `CalendarEvent/set` rather than a call each.
* The round trips are the smaller half of the reason: `createEvent`
* invalidates on the way out, and invalidating re-fetches every cached range,
* so importing a year of events one at a time would refetch the calendar a
* few hundred times.
*
* No scheduling messages. Importing a file is filing something you already
* have, and mailing its participants would be a surprise to everyone.
*/
async importIcs(text, calendarId) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([text], { type: "text/calendar" }), { type: "text/calendar" });
const events = await get().parseIcs(up.blobId);
if (!events.length) throw new Error("it has no events in it");
const create: Record<string, unknown> = {};
events.forEach((e, i) => {
const rest = forImport(e);
// A UID is what makes an event the same event across calendars, so the
// file's own is kept wherever it has one. Only what arrives without gets
// invented, and an event with no UID is not one anything can match to.
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
});
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create, sendSchedulingMessages: false });
get().invalidate();
const created = Object.keys(res.created ?? {}).length;
// Nothing at all got in: say why rather than report importing zero events
// as though the file had been empty.
if (!created) {
const first = Object.values(res.notCreated ?? {})[0];
throw new Error(first ? setErrorMessage(first) : "the server did not accept any of its events");
}
return created;
},
applyChanges(types) {
+47 -3
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, UserMinus, X } from "lucide-react";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
@@ -9,7 +9,7 @@ import { formatWeekday } from "@/lib/datetime";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types";
import type { Calendar, Id } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog";
import { plural, t } from "@/lib/i18n";
@@ -26,6 +26,28 @@ export function CalendarSidebar() {
const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu();
/*
* The file picker for "Import iCAL file". A MenuItem is a button, so it
* cannot wrap a hidden input the way the address-book import does; the input
* lives at the end of the sidebar and the menu item reaches it through this.
*
* The calendar is remembered separately because opening the picker closes the
* menu, and `menuCal` goes with it -- by the time a file comes back there
* would be nothing left saying which calendar it was chosen for.
*/
const fileRef = useRef<HTMLInputElement>(null);
const importInto = useRef<Id | null>(null);
const importFile = async (file: File) => {
const calendarId = importInto.current;
if (!calendarId) return;
try {
const n = await cal.importIcs(await file.text(), calendarId);
toast.success(plural(n, { one: "Imported {n} event", other: "Imported {n} events" }));
} catch (err) {
toast.error(t("Could not import this file: {error}", { error: (err as Error).message }));
}
};
/* Added if the server says so or the reader's settings do; Stalwart will not
always take the flag, so the settings carry it where it refuses. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares);
@@ -124,6 +146,15 @@ export function CalendarSidebar() {
<>
<MenuItem icon={cal.hidden[menuCal.id] ? <Eye size={16} /> : <EyeOff size={16} />} label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} />
<MenuItem icon={<Pencil size={16} />} label={t("Edit")} onClick={() => setEditCal(menuCal)} />
<MenuItem
icon={<Upload size={16} />}
label={t("Import iCAL file…")}
disabled={!menuCal.myRights.mayWriteAll && !menuCal.myRights.mayWriteOwn}
onClick={() => {
importInto.current = menuCal.id;
fileRef.current?.click();
}}
/>
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} onClick={() => setShare(menuCal)} disabled={!menuCal.myRights.mayShare} />
{/* Revoking every share at once, without walking the dialog and
removing people one at a time. Only offered when there is
@@ -156,6 +187,19 @@ export function CalendarSidebar() {
</>
)}
</Popover>
{/* Cleared after every pick, so choosing the same file twice still counts
as a change and fires again. */}
<input
ref={fileRef}
type="file"
accept=".ics,.ical,text/calendar"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void importFile(file);
}}
/>
{editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</div>