Import an iCal file into a calendar
An .ics reaches you by ways that are not your mailbox -- a ticketing system a customer invited, a colleague's export, a booking confirmation forwarded on -- and until now the only events ihasmail could take were the ones attached to a message it had received. The calendar's own menu now offers "Import iCAL file…", which files everything in the file into that calendar. No global button: the issue is right that this is not a frequent enough thing to earn one. The parsing is the server's, through the same CalendarEvent/parse an emailed invitation already goes through. An .ics is not a format worth reimplementing in a browser, and Stalwart's reader handles what a hand-rolled one would not. Every event goes out in a single CalendarEvent/set. The round trips are the smaller half of the reason: createEvent invalidates on the way out and invalidating refetches every cached range, so a year of events imported one at a time would refetch the calendar a few hundred times. Nothing is mailed to anyone named in the file. Importing is filing something you already have, and scheduling messages would be a surprise to its participants. The mock's parser read the whole file with one regex and returned one event, which is all an invitation ever needed. It now reads per VEVENT, so a multi-event file can be tested against it, and it invents an organiser and an attendee only for events that carry a METHOD -- a plain export is not addressed to anyone. Closes #173
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user