diff --git a/FEATURES.md b/FEATURES.md index 2c97e61..8253e4c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index cbd7efd..131158b 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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:ada@example.org", 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 = { // 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:ada@example.org", 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), diff --git a/web/src/store/__tests__/ics-import.test.ts b/web/src/store/__tests__/ics-import.test.ts new file mode 100644 index 0000000..e6062b0 --- /dev/null +++ b/web/src/store/__tests__/ics-import.test.ts @@ -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: "uid-one@example.org", 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>; 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 } = {}) { + 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][] }; + 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>, 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((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("uid-one@example.org"); + 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); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index da05dec..b736d1e 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -284,6 +284,8 @@ interface CalendarState { findByUid(uid: string): Promise; parseIcs(blobId: Id): Promise; importEvent(event: Partial, calendarId: Id): Promise; + /** Import a whole .ics file. Returns how many events it created. */ + importIcs(text: string, calendarId: Id): Promise; applyChanges(types: Set): 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): Partial { + 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((set, get) => ({ accountId: null, available: false, @@ -673,8 +689,49 @@ export const useCalendar = create((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 = {}; + 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>("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) { diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index d66fa57..456ee10 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -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(null); + const importInto = useRef(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() { <> : } label={cal.hidden[menuCal.id] ? "Show" : "Hide"} onClick={() => cal.toggleHidden(menuCal.id)} /> } label={t("Edit")} onClick={() => setEditCal(menuCal)} /> + } + label={t("Import iCAL file…")} + disabled={!menuCal.myRights.mayWriteAll && !menuCal.myRights.mayWriteOwn} + onClick={() => { + importInto.current = menuCal.id; + fileRef.current?.click(); + }} + /> } 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() { )} + {/* Cleared after every pick, so choosing the same file twice still counts + as a change and fires again. */} + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void importFile(file); + }} + /> {editCal && setEditCal(null)} />} {share && setShare(null)} />}