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:
2026-09-01 08:38:06 -07:00
parent 1a29784848
commit 6431ec87f5
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:vacationresponse` | Out of office | The Settings section hides |
| `urn:ietf:params:jmap:sieve` | Filters, visual and raw | Filters 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: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` | 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: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 | | `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 right-click one of someone else's to remove it from your view, which changes
nothing for anybody else. 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 ## Events
Created by clicking an empty slot or dragging across a range; a context menu on 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 * still saying the update succeeded. A mock that applied them would let a
* client that sends them look correct everywhere except a real server. * 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) { function calendarEventSet(a: Obj) {
const created: Obj = {}; const created: Obj = {};
const updated: 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 // 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. // #26 and #30 reached a live server unnoticed — so it now does both.
"CalendarEvent/set": (a) => calendarEventSet(a), "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), "ParticipantIdentity/get": genericGet(participantIdentities),
"Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }), "Principal/query": () => ({ accountId: ACCOUNT, queryState: "1", canCalculateChanges: false, position: 0, ids: principals.map((p) => p.id) }),
"Principal/get": genericGet(principals), "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>; findByUid(uid: string): Promise<CalendarEvent | null>;
parseIcs(blobId: Id): Promise<CalendarEvent[]>; parseIcs(blobId: Id): Promise<CalendarEvent[]>;
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>; 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; applyChanges(types: Set<string>): void;
invalidate(): void; invalidate(): void;
setDraft(draft: EventDraft | null): void; setDraft(draft: EventDraft | null): void;
@@ -302,6 +304,20 @@ const EVENT_PROPS = [
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status", "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) => ({ export const useCalendar = create<CalendarState>((set, get) => ({
accountId: null, accountId: null,
available: false, available: false,
@@ -673,8 +689,49 @@ export const useCalendar = create<CalendarState>((set, get) => ({
}, },
async importEvent(event, calendarId) { 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(forImport(event), calendarId, false);
return get().createEvent(rest, 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) { 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 { 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 { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings"; import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; 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 { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
import { confirmDialog } from "@/ui/dialog"; import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import type { Calendar } from "@/jmap/types"; import type { Calendar, Id } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog"; import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
@@ -26,6 +26,28 @@ export function CalendarSidebar() {
const [anchor, setAnchor] = useState(() => startOfDay(selected)); const [anchor, setAnchor] = useState(() => startOfDay(selected));
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const menu = useMenu(); 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 /* 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. */ always take the flag, so the settings carry it where it refuses. */
const addedShares = new Set(useSettings((s) => s.settings).addedShares); 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={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={<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} /> <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 {/* Revoking every share at once, without walking the dialog and
removing people one at a time. Only offered when there is removing people one at a time. Only offered when there is
@@ -156,6 +187,19 @@ export function CalendarSidebar() {
</> </>
)} )}
</Popover> </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)} />} {editCal && <CalendarDialog calendar={editCal} onClose={() => setEditCal(null)} />}
{share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />} {share && <ShareDialog kind="Calendar" id={share.id} name={share.name} shareWith={share.shareWith} onClose={() => setShare(null)} />}
</div> </div>