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: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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>;
|
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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user