Merge pull request #227 from Coffey-Labs/feat/calendar-ics-export
Export a calendar as an iCAL file
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toIcs, parseIcs } from "@/lib/ics";
|
||||
import type { JSCalendarEvent } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Writing iCalendar out of the server's RFC 8984 objects.
|
||||
*
|
||||
* The properties worth pinning are the ones where the two formats disagree, or
|
||||
* where getting it wrong shows up as a wrong time rather than as an error: how
|
||||
* a zone is said, what UNTIL is measured in, and where a changed occurrence
|
||||
* goes.
|
||||
*/
|
||||
|
||||
const base: JSCalendarEvent = {
|
||||
"@type": "Event", uid: "[email protected]", title: "Kickoff",
|
||||
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
const lines = (e: JSCalendarEvent[], name?: string) => toIcs(e, name).split("\r\n");
|
||||
const find = (e: JSCalendarEvent[], prefix: string) => lines(e).filter((l) => l.startsWith(prefix));
|
||||
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
|
||||
|
||||
describe("the document around the events", () => {
|
||||
it("is a calendar a reader will recognise", () => {
|
||||
const l = lines([base]);
|
||||
expect(l[0]).toBe("BEGIN:VCALENDAR");
|
||||
expect(l).toContain("VERSION:2.0");
|
||||
expect(l).toContain("END:VCALENDAR");
|
||||
expect(l.some((x) => x.startsWith("PRODID:"))).toBe(true);
|
||||
});
|
||||
|
||||
it("carries the calendar's name where a reader will look for it", () => {
|
||||
expect(lines([base], "Work")).toContain("X-WR-CALNAME:Work");
|
||||
});
|
||||
|
||||
it("ends every line the way the format requires", () => {
|
||||
expect(toIcs([base]).endsWith("\r\n")).toBe(true);
|
||||
expect(toIcs([base]).includes("\n\n")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("times and zones", () => {
|
||||
it("names the zone rather than converting, so a series survives a DST change", () => {
|
||||
expect(one(base, "DTSTART")).toBe("DTSTART;TZID=Europe/Berlin:20260902T090000");
|
||||
});
|
||||
|
||||
it("writes UTC as UTC", () => {
|
||||
expect(one({ ...base, timeZone: "Etc/UTC" }, "DTSTART")).toBe("DTSTART:20260902T090000Z");
|
||||
});
|
||||
|
||||
it("leaves a floating time floating, with no zone at all", () => {
|
||||
// No zone means "whatever clock the reader is on", which is a real and
|
||||
// different thing from UTC -- a 09:00 alarm clock, not an instant.
|
||||
expect(one({ ...base, timeZone: null }, "DTSTART")).toBe("DTSTART:20260902T090000");
|
||||
});
|
||||
|
||||
it("writes an all-day event as a date, not as midnight", () => {
|
||||
const e = { ...base, showWithoutTime: true, duration: "P1D" };
|
||||
expect(one(e, "DTSTART")).toBe("DTSTART;VALUE=DATE:20260902");
|
||||
});
|
||||
|
||||
it("keeps the duration rather than working out an end", () => {
|
||||
expect(one(base, "DURATION")).toBe("DURATION:PT1H");
|
||||
});
|
||||
|
||||
it("says nothing about duration when the event has none", () => {
|
||||
expect(find([{ ...base, duration: undefined }], "DURATION")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recurrence", () => {
|
||||
const weekly = { ...base, recurrenceRule: { frequency: "weekly" as const, byDay: [{ day: "we" as const }] } };
|
||||
|
||||
it("writes the rule rather than expanding it into a year of events", () => {
|
||||
expect(one(weekly, "RRULE")).toBe("RRULE:FREQ=WEEKLY;BYDAY=WE");
|
||||
expect(find([weekly], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reads the array form as well as the single rule Stalwart stores", () => {
|
||||
const e = { ...base, recurrenceRules: [{ frequency: "monthly" as const, interval: 2, count: 5 }] };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=5");
|
||||
});
|
||||
|
||||
it("measures UNTIL in UTC, so a series does not stop a day early elsewhere", () => {
|
||||
const e = { ...base, recurrenceRule: { frequency: "weekly" as const, until: "2026-12-30T09:00:00" } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=WEEKLY;UNTIL=20261230T090000Z");
|
||||
});
|
||||
|
||||
it("measures UNTIL as a date when the series is all-day", () => {
|
||||
const e = { ...base, showWithoutTime: true, recurrenceRule: { frequency: "daily" as const, until: "2026-12-30T00:00:00" } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=DAILY;UNTIL=20261230");
|
||||
});
|
||||
|
||||
it("keeps the nth-weekday form that BYDAY carries a number for", () => {
|
||||
const e = { ...base, recurrenceRule: { frequency: "monthly" as const, byDay: [{ day: "th" as const, nthOfPeriod: -1 }] } };
|
||||
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
|
||||
});
|
||||
|
||||
it("turns a cancelled occurrence into an EXDATE", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats an override marked excluded the same way", () => {
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { excluded: true } } };
|
||||
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
|
||||
});
|
||||
|
||||
it("gives a changed occurrence its own event, sharing the uid", () => {
|
||||
/*
|
||||
* Which is how iCalendar has always said it: the same UID, plus the
|
||||
* RECURRENCE-ID of the slot being replaced. The master keeps its rule and
|
||||
* the override must not.
|
||||
*/
|
||||
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Kickoff (moved)" } } };
|
||||
const l = lines([e]);
|
||||
expect(l.filter((x) => x === "BEGIN:VEVENT")).toHaveLength(2);
|
||||
expect(l.filter((x) => x === "UID:[email protected]")).toHaveLength(2);
|
||||
expect(l).toContain("RECURRENCE-ID;TZID=Europe/Berlin:20260909T090000");
|
||||
expect(l).toContain("SUMMARY:Kickoff (moved)");
|
||||
// One RRULE in the file, on the master.
|
||||
expect(l.filter((x) => x.startsWith("RRULE:"))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of an event", () => {
|
||||
it("escapes what the format uses as punctuation", () => {
|
||||
const e = { ...base, title: "Budget; Q4, final", description: "line one\nline two" };
|
||||
// Both escapes doubled here for JS's sake: what reaches the file is one
|
||||
// backslash before each of the two characters the format reserves.
|
||||
expect(one(e, "SUMMARY")).toBe("SUMMARY:Budget\\; Q4\\, final");
|
||||
expect(one(e, "DESCRIPTION")).toBe("DESCRIPTION:line one\\nline two");
|
||||
});
|
||||
|
||||
it("folds a long line rather than writing it past the limit", () => {
|
||||
const e = { ...base, title: "x".repeat(200) };
|
||||
for (const l of lines([e])) expect(l.length).toBeLessThanOrEqual(75);
|
||||
});
|
||||
|
||||
it("puts a room in LOCATION and a video link in URL", () => {
|
||||
// A meeting URL where a room name goes is what makes a printed agenda
|
||||
// useless, and they are different fields in both formats.
|
||||
const e = {
|
||||
...base,
|
||||
locations: { l1: { name: "Room 3" } },
|
||||
virtualLocations: { v1: { uri: "https://meet.example.org/abc" } },
|
||||
} as JSCalendarEvent;
|
||||
expect(one(e, "LOCATION")).toBe("LOCATION:Room 3");
|
||||
expect(one(e, "URL")).toBe("URL:https://meet.example.org/abc");
|
||||
});
|
||||
|
||||
it("maps the words the two formats spell differently", () => {
|
||||
const e = { ...base, status: "tentative" as const, privacy: "secret" as const, freeBusyStatus: "free" as const };
|
||||
expect(one(e, "STATUS")).toBe("STATUS:TENTATIVE");
|
||||
expect(one(e, "CLASS")).toBe("CLASS:CONFIDENTIAL");
|
||||
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
|
||||
});
|
||||
|
||||
it("writes the organiser and the guests, with what each answered", () => {
|
||||
const e = {
|
||||
...base,
|
||||
organizerCalendarAddress: "mailto:[email protected]",
|
||||
participants: {
|
||||
p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:[email protected]", participationStatus: "accepted" as const, expectReply: true },
|
||||
p2: { roles: { optional: true }, sendTo: { imip: "mailto:[email protected]" }, participationStatus: "needs-action" as const },
|
||||
},
|
||||
} as JSCalendarEvent;
|
||||
expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:[email protected]");
|
||||
const att = find([e], "ATTENDEE");
|
||||
expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:[email protected]");
|
||||
expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:[email protected]");
|
||||
});
|
||||
|
||||
it("skips a participant with no address at all rather than writing a broken line", () => {
|
||||
const e = { ...base, participants: { p1: { roles: { attendee: true }, name: "Nobody" } } } as JSCalendarEvent;
|
||||
expect(find([e], "ATTENDEE")).toEqual([]);
|
||||
});
|
||||
|
||||
it("nests an alarm inside the event it belongs to", () => {
|
||||
const e = { ...base, alerts: { a1: { trigger: { offset: "-PT15M" } } } } as JSCalendarEvent;
|
||||
const l = lines([e]);
|
||||
expect(l).toContain("BEGIN:VALARM");
|
||||
expect(l).toContain("TRIGGER:-PT15M");
|
||||
expect(l).toContain("ACTION:DISPLAY");
|
||||
expect(l.indexOf("BEGIN:VALARM")).toBeLessThan(l.indexOf("END:VEVENT"));
|
||||
});
|
||||
|
||||
it("says when an alarm hangs off the end rather than the start", () => {
|
||||
const e = { ...base, alerts: { a1: { trigger: { offset: "PT5M", relativeTo: "end" as const } } } } as JSCalendarEvent;
|
||||
expect(one(e, "TRIGGER")).toBe("TRIGGER;RELATED=END:PT5M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what comes back out of the parser", () => {
|
||||
/*
|
||||
* Not a full round trip -- the reader is a subscription parser and keeps far
|
||||
* less than the writer emits -- but what it does read should be what went in.
|
||||
*/
|
||||
it("reads back the events it wrote", () => {
|
||||
const two = [base, { ...base, uid: "[email protected]", title: "Retro", start: "2026-09-09T14:00:00" }];
|
||||
const back = parseIcs(toIcs(two));
|
||||
expect(back.events.map((e) => e.uid)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(back.events.map((e) => e.summary)).toEqual(["Kickoff", "Retro"]);
|
||||
});
|
||||
|
||||
it("reads back a title that needed escaping, unescaped", () => {
|
||||
const back = parseIcs(toIcs([{ ...base, title: "Budget; Q4, final" }]));
|
||||
expect(back.events[0]!.summary).toBe("Budget; Q4, final");
|
||||
});
|
||||
});
|
||||
+222
-1
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Reading an iCalendar document (RFC 5545), enough of one to draw it.
|
||||
* Reading an iCalendar document (RFC 5545), enough of one to draw it -- and,
|
||||
* from `toIcs` at the foot of the file, writing one back out.
|
||||
*
|
||||
* The two halves are not symmetrical and are not meant to be. Reading serves
|
||||
* subscriptions; writing serves export, and starts from the server's RFC 8984
|
||||
* objects rather than from anything this parser produced.
|
||||
*
|
||||
* This is a *subscription* parser, not an importer. A subscribed calendar is
|
||||
* read-only and redrawn from scratch on every refresh, so nothing here has to
|
||||
@@ -13,6 +18,8 @@
|
||||
* would be worse than one that shows the first occurrence and says so.
|
||||
*/
|
||||
|
||||
import type { JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule } from "@/jmap/types";
|
||||
|
||||
export interface IcsEvent {
|
||||
uid: string;
|
||||
summary: string;
|
||||
@@ -259,3 +266,217 @@ function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): Ics
|
||||
recurring: Boolean(e.recurring),
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Writing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* JSCalendar out to iCalendar.
|
||||
*
|
||||
* The reverse of everything above, and a narrower job than it looks: the events
|
||||
* come from the server as RFC 8984 objects, and RFC 8984 was written as a
|
||||
* restatement of RFC 5545, so most of this is renaming. Where the two disagree
|
||||
* the comments say which way it went and why.
|
||||
*
|
||||
* What is deliberately not here, stated rather than discovered:
|
||||
*
|
||||
* - **No VTIMEZONE components.** A `TZID` is emitted with the IANA name the
|
||||
* server holds -- "Europe/Berlin" -- and no definition of that zone beside
|
||||
* it. Generating one means shipping a zone database to the browser to
|
||||
* describe rules the reader's own system already knows. Every client that
|
||||
* matters resolves IANA names; a strict validator will complain, and the
|
||||
* alternative -- converting everything to UTC -- would be worse, because a
|
||||
* weekly 09:00 that becomes 08:00 for half the year is a wrong calendar
|
||||
* rather than a pedantic one.
|
||||
* - **Overrides are applied at the top level only.** A recurrence override is a
|
||||
* JSON patch, and a patch addressing `locations/x/name` is not something this
|
||||
* flattens; those paths are left on the master's value. Plain overridden
|
||||
* properties -- a moved time, a changed title -- come across.
|
||||
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
|
||||
* in ihasmail sets them.
|
||||
*/
|
||||
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
|
||||
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
|
||||
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
|
||||
for (const e of events) lines.push(...vevent(e));
|
||||
lines.push("END:VCALENDAR");
|
||||
return lines.map(foldLine).join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
|
||||
function escText(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** 75 octets is the limit; a continuation begins with one space. */
|
||||
function foldLine(line: string): string {
|
||||
if (line.length <= 75) return line;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
out.push((i ? " " : "") + line.slice(i, i + 74));
|
||||
i += 74;
|
||||
}
|
||||
return out.join("\r\n");
|
||||
}
|
||||
|
||||
/** "2026-09-02T09:00:00" -> "20260902T090000"; the date half alone for all-day. */
|
||||
function stamp(local: string, dateOnly = false): string {
|
||||
const compact = local.replace(/[-:]/g, "").replace(/\.\d+/, "");
|
||||
return dateOnly ? compact.slice(0, 8) : compact.slice(0, 15);
|
||||
}
|
||||
|
||||
/** A UTC instant as iCalendar spells it. */
|
||||
function utcStamp(iso: string): string {
|
||||
return `${iso.replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15)}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date-time property with its zone said the way the zone requires.
|
||||
*
|
||||
* Three shapes, and the difference matters: a floating time carries no zone and
|
||||
* means "whatever clock the reader is on", UTC carries the Z, and everything
|
||||
* else names an IANA zone in TZID.
|
||||
*/
|
||||
function dateProp(name: string, local: string, timeZone: string | null | undefined, allDay: boolean): string {
|
||||
if (allDay) return `${name};VALUE=DATE:${stamp(local, true)}`;
|
||||
if (!timeZone) return `${name}:${stamp(local)}`;
|
||||
if (timeZone === "Etc/UTC" || timeZone === "UTC") return `${name}:${stamp(local)}Z`;
|
||||
return `${name};TZID=${timeZone}:${stamp(local)}`;
|
||||
}
|
||||
|
||||
const STATUS: Record<string, string> = { confirmed: "CONFIRMED", cancelled: "CANCELLED", tentative: "TENTATIVE" };
|
||||
const CLASS: Record<string, string> = { public: "PUBLIC", private: "PRIVATE", secret: "CONFIDENTIAL" };
|
||||
const PARTSTAT: Record<string, string> = {
|
||||
"needs-action": "NEEDS-ACTION", accepted: "ACCEPTED", declined: "DECLINED",
|
||||
tentative: "TENTATIVE", delegated: "DELEGATED",
|
||||
};
|
||||
|
||||
/** A participant's address, wherever this server keeps it. */
|
||||
function participantAddress(p: JSCalendarParticipant): string | null {
|
||||
return p.calendarAddress ?? p.sendTo?.imip ?? (p.email ? `mailto:${p.email}` : null) ?? null;
|
||||
}
|
||||
|
||||
function vevent(e: JSCalendarEvent, recurrenceId?: { local: string; timeZone: string | null | undefined; allDay: boolean }): string[] {
|
||||
const allDay = Boolean(e.showWithoutTime);
|
||||
const tz = allDay ? null : e.timeZone;
|
||||
const out = ["BEGIN:VEVENT", `UID:${e.uid}`];
|
||||
|
||||
/* DTSTAMP is required and means "when this description was made", which for
|
||||
an export is the last time the event changed. */
|
||||
out.push(`DTSTAMP:${utcStamp(e.updated ?? e.created ?? new Date().toISOString())}`);
|
||||
out.push(dateProp("DTSTART", e.start, tz, allDay));
|
||||
/* DURATION rather than DTEND, because that is what JSCalendar holds and
|
||||
converting would mean doing the zone arithmetic here to no purpose. */
|
||||
if (e.duration && e.duration !== "PT0S") out.push(`DURATION:${e.duration}`);
|
||||
if (recurrenceId) out.push(dateProp("RECURRENCE-ID", recurrenceId.local, recurrenceId.timeZone, recurrenceId.allDay));
|
||||
|
||||
if (e.title) out.push(`SUMMARY:${escText(e.title)}`);
|
||||
if (e.description) out.push(`DESCRIPTION:${escText(e.description)}`);
|
||||
const location = Object.values(e.locations ?? {}).map((l) => l.name).filter(Boolean)[0];
|
||||
if (location) out.push(`LOCATION:${escText(location)}`);
|
||||
/* A virtual location is a URL and belongs in URL, not LOCATION: putting a
|
||||
video link where a room name goes is what makes an agenda unreadable. */
|
||||
const virtual = Object.values(e.virtualLocations ?? {}).map((v) => v.uri).filter(Boolean)[0];
|
||||
const link = Object.values(e.links ?? {}).map((l) => l.href).filter(Boolean)[0];
|
||||
if (virtual ?? link) out.push(`URL:${virtual ?? link}`);
|
||||
|
||||
const categories = [...Object.keys(e.keywords ?? {}), ...Object.keys(e.categories ?? {})];
|
||||
if (categories.length) out.push(`CATEGORIES:${categories.map(escText).join(",")}`);
|
||||
if (e.status && STATUS[e.status]) out.push(`STATUS:${STATUS[e.status]}`);
|
||||
if (e.privacy && CLASS[e.privacy]) out.push(`CLASS:${CLASS[e.privacy]}`);
|
||||
/* TRANSP is about whether the time is busy, which is the same question
|
||||
freeBusyStatus answers and the opposite word for it. */
|
||||
if (e.freeBusyStatus) out.push(`TRANSP:${e.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
|
||||
if (e.priority != null) out.push(`PRIORITY:${e.priority}`);
|
||||
if (e.sequence != null) out.push(`SEQUENCE:${e.sequence}`);
|
||||
if (e.created) out.push(`CREATED:${utcStamp(e.created)}`);
|
||||
if (e.updated) out.push(`LAST-MODIFIED:${utcStamp(e.updated)}`);
|
||||
if (e.color) out.push(`COLOR:${e.color}`);
|
||||
|
||||
const organizer = e.organizerCalendarAddress ?? e.replyTo?.imip;
|
||||
if (organizer) out.push(`ORGANIZER:${organizer}`);
|
||||
for (const p of Object.values(e.participants ?? {})) {
|
||||
const address = participantAddress(p);
|
||||
if (!address) continue;
|
||||
const params = [
|
||||
p.name ? `CN=${escText(p.name)}` : "",
|
||||
p.participationStatus && PARTSTAT[p.participationStatus] ? `PARTSTAT=${PARTSTAT[p.participationStatus]}` : "",
|
||||
p.roles?.chair ? "ROLE=CHAIR" : p.roles?.optional ? "ROLE=OPT-PARTICIPANT" : "",
|
||||
p.expectReply ? "RSVP=TRUE" : "",
|
||||
].filter(Boolean);
|
||||
out.push(`ATTENDEE${params.length ? `;${params.join(";")}` : ""}:${address}`);
|
||||
}
|
||||
|
||||
/* Stalwart 0.16 names a single rule `recurrenceRule`; RFC 8984 says
|
||||
`recurrenceRules`. Both are read, because both turn up. */
|
||||
for (const rule of [...(e.recurrenceRules ?? []), ...(e.recurrenceRule ? [e.recurrenceRule] : [])]) {
|
||||
out.push(`RRULE:${rrule(rule, allDay)}`);
|
||||
}
|
||||
const excluded: string[] = [];
|
||||
const modified: Array<[string, Record<string, unknown>]> = [];
|
||||
for (const [when, patch] of Object.entries(e.recurrenceOverrides ?? {})) {
|
||||
if (patch === null || (patch as Record<string, unknown>).excluded === true) excluded.push(when);
|
||||
else modified.push([when, patch as Record<string, unknown>]);
|
||||
}
|
||||
if (excluded.length) {
|
||||
out.push(allDay
|
||||
? `EXDATE;VALUE=DATE:${excluded.map((d) => stamp(d, true)).join(",")}`
|
||||
: tz
|
||||
? `EXDATE;TZID=${tz}:${excluded.map((d) => stamp(d)).join(",")}`
|
||||
: `EXDATE:${excluded.map((d) => stamp(d)).join(",")}`);
|
||||
}
|
||||
/*
|
||||
* An alarm is a component, not a property, so it nests inside the event. Only
|
||||
* DISPLAY and EMAIL are written because they are the only two JSCalendar
|
||||
* names, and an acknowledged alert is still exported -- whether it has fired
|
||||
* is this reader's business, not the file's.
|
||||
*/
|
||||
for (const a of Object.values(e.alerts ?? {})) {
|
||||
const trigger = "offset" in a.trigger
|
||||
? `TRIGGER${a.trigger.relativeTo === "end" ? ";RELATED=END" : ""}:${a.trigger.offset}`
|
||||
: `TRIGGER;VALUE=DATE-TIME:${utcStamp(a.trigger.when)}`;
|
||||
out.push("BEGIN:VALARM", trigger, `ACTION:${a.action === "email" ? "EMAIL" : "DISPLAY"}`, `DESCRIPTION:${escText(e.title ?? "")}`, "END:VALARM");
|
||||
}
|
||||
out.push("END:VEVENT");
|
||||
|
||||
/* A changed occurrence is its own VEVENT carrying the same UID and the
|
||||
RECURRENCE-ID of the slot it replaces -- which is how iCalendar has always
|
||||
said it, and why these come after the master rather than inside it. */
|
||||
for (const [when, patch] of modified) {
|
||||
const merged = { ...e, ...patch } as JSCalendarEvent;
|
||||
delete merged.recurrenceRules;
|
||||
delete merged.recurrenceRule;
|
||||
delete merged.recurrenceOverrides;
|
||||
out.push(...vevent(merged, { local: when, timeZone: tz, allDay }));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FREQ: Record<string, string> = {
|
||||
yearly: "YEARLY", monthly: "MONTHLY", weekly: "WEEKLY", daily: "DAILY",
|
||||
hourly: "HOURLY", minutely: "MINUTELY", secondly: "SECONDLY",
|
||||
};
|
||||
const DAYS: Record<string, string> = { mo: "MO", tu: "TU", we: "WE", th: "TH", fr: "FR", sa: "SA", su: "SU" };
|
||||
|
||||
function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
|
||||
const parts = [`FREQ=${FREQ[r.frequency] ?? r.frequency.toUpperCase()}`];
|
||||
if (r.interval && r.interval !== 1) parts.push(`INTERVAL=${r.interval}`);
|
||||
if (r.count != null) parts.push(`COUNT=${r.count}`);
|
||||
/* UNTIL has to match DTSTART's kind: a date for an all-day series, and a UTC
|
||||
instant otherwise. Sending a local time here is the classic way to make a
|
||||
series stop on the wrong day in another zone. */
|
||||
if (r.until) parts.push(`UNTIL=${allDay ? stamp(r.until, true) : `${stamp(r.until)}Z`}`);
|
||||
if (r.byDay?.length) parts.push(`BYDAY=${r.byDay.map((d) => `${d.nthOfPeriod ?? ""}${DAYS[d.day] ?? d.day.toUpperCase()}`).join(",")}`);
|
||||
if (r.byMonthDay?.length) parts.push(`BYMONTHDAY=${r.byMonthDay.join(",")}`);
|
||||
if (r.byMonth?.length) parts.push(`BYMONTH=${r.byMonth.join(",")}`);
|
||||
if (r.byYearDay?.length) parts.push(`BYYEARDAY=${r.byYearDay.join(",")}`);
|
||||
if (r.byWeekNo?.length) parts.push(`BYWEEKNO=${r.byWeekNo.join(",")}`);
|
||||
if (r.byHour?.length) parts.push(`BYHOUR=${r.byHour.join(",")}`);
|
||||
if (r.byMinute?.length) parts.push(`BYMINUTE=${r.byMinute.join(",")}`);
|
||||
if (r.bySecond?.length) parts.push(`BYSECOND=${r.bySecond.join(",")}`);
|
||||
if (r.bySetPosition?.length) parts.push(`BYSETPOS=${r.bySetPosition.join(",")}`);
|
||||
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "iCAL-Datei exportieren",
|
||||
"Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Archivieren",
|
||||
"Archive (e)": "Archivieren (e)",
|
||||
@@ -1076,6 +1078,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Hier ist nichts ungelesen",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
|
||||
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
|
||||
"Already here: {n} events, nothing imported": { one: "Bereits vorhanden: {n} Termin, nichts importiert", other: "Bereits vorhanden: {n} Termine, nichts importiert" },
|
||||
"{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
|
||||
|
||||
@@ -47,6 +47,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "Exportar archivo iCAL",
|
||||
"Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Archivar",
|
||||
"Archive (e)": "Archivar (e)",
|
||||
@@ -1049,6 +1051,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Aquí no hay nada sin leer",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
||||
"Already here: {n} events, nothing imported": { one: "Ya estaba aquí: {n} evento, no se importó nada", other: "Ya estaban aquí: {n} eventos, no se importó nada" },
|
||||
"{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
|
||||
|
||||
@@ -52,6 +52,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "Exporter un fichier iCAL",
|
||||
"Could not export this calendar: {error}": "Impossible d’exporter ce calendrier : {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Archiver",
|
||||
"Archive (e)": "Archiver (e)",
|
||||
@@ -1054,6 +1056,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Rien de non lu ici",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
|
||||
"Imported {n} events": { one: "{n} événement importé", other: "{n} événements importés" },
|
||||
"Already here: {n} events, nothing imported": { one: "Déjà présent : {n} événement, rien d’importé", other: "Déjà présents : {n} événements, rien d’importé" },
|
||||
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
|
||||
|
||||
@@ -46,6 +46,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "iCAL ファイルをエクスポート",
|
||||
"Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "アーカイブ",
|
||||
"Archive (e)": "アーカイブ (e)",
|
||||
@@ -1057,6 +1059,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "ここに未読はありません",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
||||
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
|
||||
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
|
||||
"{n} were already here": { other: "{n} 件はすでに存在していました" },
|
||||
|
||||
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "iCAL-bestand exporteren",
|
||||
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Archiveren",
|
||||
"Archive (e)": "Archiveren (e)",
|
||||
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Hier is niets ongelezen",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
|
||||
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
|
||||
"Already here: {n} events, nothing imported": { one: "Al aanwezig: {n} afspraak, niets geïmporteerd", other: "Al aanwezig: {n} afspraken, niets geïmporteerd" },
|
||||
"{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
|
||||
|
||||
@@ -50,6 +50,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "Exportar arquivo iCAL",
|
||||
"Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Arquivar",
|
||||
"Archive (e)": "Arquivar (e)",
|
||||
@@ -1052,6 +1054,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Não há nada não lido aqui",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
||||
"Already here: {n} events, nothing imported": { one: "Já estava aqui: {n} evento, nada importado", other: "Já estavam aqui: {n} eventos, nada importado" },
|
||||
"{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
|
||||
|
||||
@@ -49,6 +49,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "Экспортировать файл iCAL",
|
||||
"Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Архивировать",
|
||||
"Archive (e)": "Архивировать (e)",
|
||||
@@ -1051,6 +1053,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Здесь нет непрочитанного",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
|
||||
"Imported {n} events": { one: "Импортировано {n} событие", few: "Импортировано {n} события", many: "Импортировано {n} событий", other: "Импортировано {n} события" },
|
||||
"Already here: {n} events, nothing imported": { one: "Уже есть: {n} событие, ничего не импортировано", few: "Уже есть: {n} события, ничего не импортировано", many: "Уже есть: {n} событий, ничего не импортировано", other: "Уже есть: {n} события, ничего не импортировано" },
|
||||
"{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
|
||||
|
||||
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "Експортувати файл iCAL",
|
||||
"Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "Архівувати",
|
||||
"Archive (e)": "Архівувати (e)",
|
||||
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "Тут немає непрочитаного",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
|
||||
"Imported {n} events": { one: "Імпортовано {n} подію", few: "Імпортовано {n} події", many: "Імпортовано {n} подій", other: "Імпортовано {n} події" },
|
||||
"Already here: {n} events, nothing imported": { one: "Уже є: {n} подія, нічого не імпортовано", few: "Уже є: {n} події, нічого не імпортовано", many: "Уже є: {n} подій, нічого не імпортовано", other: "Уже є: {n} події, нічого не імпортовано" },
|
||||
"{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
|
||||
|
||||
@@ -45,6 +45,8 @@ import type { Catalog } from "@/lib/i18n";
|
||||
*/
|
||||
export const catalog: Catalog = {
|
||||
strings: {
|
||||
"Export iCAL file": "导出 iCAL 文件",
|
||||
"Could not export this calendar: {error}": "无法导出此日历:{error}",
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
"Archive": "归档",
|
||||
"Archive (e)": "归档 (e)",
|
||||
@@ -1056,6 +1058,7 @@ export const catalog: Catalog = {
|
||||
"Nothing unread here": "这里没有未读邮件",
|
||||
},
|
||||
plurals: {
|
||||
"Exported {n} events": { other: "已导出 {n} 个日程" },
|
||||
"Imported {n} events": { other: "已导入 {n} 个日程" },
|
||||
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
|
||||
"{n} were already here": { other: "{n} 个已存在" },
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import type { CalendarEvent, JmapSession } from "@/jmap/types";
|
||||
|
||||
/*
|
||||
* Exporting a calendar: the read side of it, which the writer's own tests do
|
||||
* not cover. What matters here is which events are collected -- this calendar's
|
||||
* and not the account's, masters and not occurrences -- and that a calendar too
|
||||
* big for one page still comes out whole.
|
||||
*/
|
||||
|
||||
const MAX = 500;
|
||||
|
||||
function server(events: Array<Partial<CalendarEvent> & { id: string }>) {
|
||||
const queries: Array<{ position: number; expand: boolean }> = [];
|
||||
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/query") {
|
||||
const position = (args.position as number) ?? 0;
|
||||
queries.push({ position, expand: Boolean(args.expandRecurrences) });
|
||||
const limit = (args.limit as number) ?? MAX;
|
||||
return [name, { accountId: "a1", queryState: "1", canCalculateChanges: false, position, ids: events.slice(position, position + limit).map((e) => e.id), total: events.length }, id];
|
||||
}
|
||||
if (name === "CalendarEvent/get") {
|
||||
const want = new Set((args.ids as string[]) ?? []);
|
||||
return [name, { accountId: "a1", state: "1", list: events.filter((e) => want.has(e.id)), notFound: [] }, 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 queries;
|
||||
}
|
||||
|
||||
const ev = (id: string, uid: string, calendarId: string, title = "Event"): Partial<CalendarEvent> & { id: string } => ({
|
||||
id, uid, title, calendarIds: { [calendarId]: true },
|
||||
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.calendars]: {} },
|
||||
accounts: {}, primaryAccounts: {}, state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useCalendar.setState({
|
||||
accountId: "a1", available: true,
|
||||
calendars: { cal1: { id: "cal1", name: "Work" } } as never,
|
||||
events: {}, ranges: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("exporting a calendar", () => {
|
||||
it("takes this calendar's events and leaves the others alone", async () => {
|
||||
server([ev("e1", "one@x", "cal1"), ev("e2", "two@x", "cal2"), ev("e3", "three@x", "cal1")]);
|
||||
const { text, count } = await useCalendar.getState().exportIcs("cal1");
|
||||
expect(count).toBe(2);
|
||||
expect(text).toContain("UID:one@x");
|
||||
expect(text).toContain("UID:three@x");
|
||||
expect(text).not.toContain("UID:two@x");
|
||||
});
|
||||
|
||||
it("names the calendar in the file", async () => {
|
||||
server([ev("e1", "one@x", "cal1")]);
|
||||
const { text } = await useCalendar.getState().exportIcs("cal1");
|
||||
expect(text).toContain("X-WR-CALNAME:Work");
|
||||
});
|
||||
|
||||
it("asks for masters, not for every occurrence of every series", async () => {
|
||||
// With expandRecurrences a year of a weekly event is fifty-two ids, and the
|
||||
// file would carry fifty-two VEVENTs instead of one with an RRULE.
|
||||
const queries = server([ev("e1", "one@x", "cal1")]);
|
||||
await useCalendar.getState().exportIcs("cal1");
|
||||
expect(queries.every((q) => !q.expand)).toBe(true);
|
||||
});
|
||||
|
||||
it("pages through a calendar bigger than one request", async () => {
|
||||
const many = Array.from({ length: 1200 }, (_, i) => ev(`e${i}`, `uid-${i}@x`, "cal1"));
|
||||
const queries = server(many);
|
||||
const { text, count } = await useCalendar.getState().exportIcs("cal1");
|
||||
expect(count).toBe(1200);
|
||||
// Three, not four: the server reports a total, so the last page is known to
|
||||
// be the last and the round trip that would have discovered it is skipped.
|
||||
expect(queries.map((q) => q.position)).toEqual([0, 500, 1000]);
|
||||
expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(1200);
|
||||
});
|
||||
|
||||
it("says an empty calendar is empty rather than handing over a file with nothing in it", async () => {
|
||||
server([ev("e1", "one@x", "cal2")]);
|
||||
await expect(useCalendar.getState().exportIcs("cal1")).rejects.toThrow(/nothing in it/);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browser
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useContacts } from "./contacts";
|
||||
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays";
|
||||
import { looksLikeCalendar, parseIcs, type IcsEvent } from "@/lib/ics";
|
||||
import { looksLikeCalendar, parseIcs, toIcs, type IcsEvent } from "@/lib/ics";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { settings, useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
@@ -298,6 +298,8 @@ interface CalendarState {
|
||||
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
|
||||
/** Import a whole .ics file. Says how many it created, and how many were already here. */
|
||||
importIcs(text: string, calendarId: Id): Promise<{ created: number; skipped: number }>;
|
||||
/** The whole calendar as one .ics document, and how many events went into it. */
|
||||
exportIcs(calendarId: Id): Promise<{ text: string; count: number }>;
|
||||
applyChanges(types: Set<string>): void;
|
||||
invalidate(): void;
|
||||
setDraft(draft: EventDraft | null): void;
|
||||
@@ -346,23 +348,29 @@ function forImport(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
|
||||
* calendars, and `calendarIds` says which without relying on a filter this
|
||||
* client has not confirmed the server supports.
|
||||
*/
|
||||
async function uidsInCalendar(accountId: Id, calendarId: Id): Promise<Set<string>> {
|
||||
const uids = new Set<string>();
|
||||
async function eventsInCalendar(accountId: Id, calendarId: Id, properties: string[]): Promise<CalendarEvent[]> {
|
||||
const found: CalendarEvent[] = [];
|
||||
const page = client.maxObjectsInGet;
|
||||
for (let position = 0; ; ) {
|
||||
const q = await client.call<QueryResponse>("CalendarEvent/query", { accountId, position, limit: page });
|
||||
const ids = q.ids ?? [];
|
||||
if (!ids.length) break;
|
||||
for (const part of chunk(ids, page)) {
|
||||
const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties: ["uid", "calendarIds"] });
|
||||
for (const e of g.list) if (e.uid && e.calendarIds?.[calendarId]) uids.add(e.uid);
|
||||
const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties });
|
||||
for (const e of g.list) if (e.calendarIds?.[calendarId]) found.push(e);
|
||||
}
|
||||
position += ids.length;
|
||||
// `total` is optional, so the empty page above is what actually ends this;
|
||||
// this only saves the round trip that would find it.
|
||||
if (q.total != null && position >= q.total) break;
|
||||
}
|
||||
return uids;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Just the UIDs, for deciding what a re-import would duplicate. */
|
||||
async function uidsInCalendar(accountId: Id, calendarId: Id): Promise<Set<string>> {
|
||||
const events = await eventsInCalendar(accountId, calendarId, ["uid", "calendarIds"]);
|
||||
return new Set(events.map((e) => e.uid).filter(Boolean));
|
||||
}
|
||||
|
||||
export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
@@ -910,6 +918,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
|
||||
return { created, skipped };
|
||||
},
|
||||
|
||||
/*
|
||||
* The calendar out to a file, which is the import read backwards.
|
||||
*
|
||||
* The masters, not the occurrences: the query runs without
|
||||
* `expandRecurrences`, so a weekly series leaves here as one VEVENT carrying
|
||||
* its RRULE rather than as a year of identical ones. An export that had
|
||||
* flattened the rule would import somewhere else as an unmaintainable pile.
|
||||
*
|
||||
* Written in the browser, unlike the import, which hands the parsing to the
|
||||
* server. There is no `CalendarEvent/serialise` to hand this to -- the JMAP
|
||||
* calendar drafts define parsing and nothing the other way -- so it is done
|
||||
* here from the objects the server already returns.
|
||||
*/
|
||||
async exportIcs(calendarId) {
|
||||
const accountId = get().accountId!;
|
||||
const events = await eventsInCalendar(accountId, calendarId, EVENT_PROPS);
|
||||
if (!events.length) throw new Error("there is nothing in it to export");
|
||||
return { text: toIcs(events, get().calendars[calendarId]?.name), count: events.length };
|
||||
},
|
||||
|
||||
applyChanges(types) {
|
||||
if (types.has("Calendar")) void get().loadCalendars();
|
||||
if (types.has("CalendarEvent")) get().invalidate();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Download, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X, AlertTriangle } from "lucide-react";
|
||||
import { useCalendar } from "@/store/calendar";
|
||||
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||
@@ -41,6 +41,26 @@ export function CalendarSidebar() {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const importInto = useRef<Id | null>(null);
|
||||
|
||||
/*
|
||||
* Handing the file over, which the browser only does from a click. The
|
||||
* revoke below is what keeps a calendar's worth of text from sitting in
|
||||
* memory after the download has started.
|
||||
*/
|
||||
const exportFile = async (c: Calendar) => {
|
||||
try {
|
||||
const { text, count } = await cal.exportIcs(c.id);
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
};
|
||||
|
||||
const importFile = async (file: File) => {
|
||||
const calendarId = importInto.current;
|
||||
if (!calendarId) return;
|
||||
@@ -219,6 +239,9 @@ export function CalendarSidebar() {
|
||||
fileRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
{/* No rights test: exporting is reading, and a calendar you cannot
|
||||
read is not in this list to begin with. */}
|
||||
<MenuItem icon={<Download size={16} />} label={t("Export iCAL file")} onClick={() => void exportFile(menuCal)} />
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user