Merge pull request #178 from Coffey-Labs/feat/ical-import
Import an iCal file into a calendar
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user