Export a calendar as an iCAL file

The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

It is written here rather than asked for. The import hands parsing to the
server because Stalwart has a CalendarEvent/parse and reimplementing an .ics
reader in a browser would be foolish; there is no method the other way, in
Stalwart or in the JMAP calendar drafts, so the file is built from the RFC
8984 objects the server already returns. Most of that is renaming: 8984 was
written as a restatement of 5545, and the comments say which way it went
wherever the two disagree.

The masters, not the occurrences. The query runs without expandRecurrences,
so a weekly meeting leaves 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 a pile nobody can maintain. A changed occurrence goes out
as its own VEVENT with the same UID and a RECURRENCE-ID, which is how
iCalendar has always said it; a cancelled one becomes an EXDATE.

Three decisions worth stating rather than leaving to be found:

No VTIMEZONE components. A TZID names the IANA zone the server holds and
nothing defines it beside it, because defining it means shipping a zone
database to describe rules the reader's own system already knows. Every
client that matters resolves IANA names. The alternative -- converting to
UTC -- would be worse than a validator's complaint: a weekly 09:00 that
becomes 08:00 for half the year is a wrong calendar.

UNTIL follows DTSTART's kind, a date for an all-day series and a UTC instant
otherwise. Sending a local time there is the usual way to make a series stop
a day early in another timezone.

Overrides are applied at the top level only. A recurrence override is a JSON
patch, and one addressing locations/x/name is not something this flattens.

Closes #216.
This commit is contained in:
2026-09-02 09:21:16 -07:00
parent cb69115be1
commit 1a6158aa70
14 changed files with 617 additions and 8 deletions
+211
View File
@@ -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
View File
@@ -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 * 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 * 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. * would be worse than one that shows the first occurrence and says so.
*/ */
import type { JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule } from "@/jmap/types";
export interface IcsEvent { export interface IcsEvent {
uid: string; uid: string;
summary: string; summary: string;
@@ -259,3 +266,217 @@ function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): Ics
recurring: Boolean(e.recurring), 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(";");
}
+3
View File
@@ -55,6 +55,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "iCAL-Datei exportieren",
"Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Archivieren", "Archive": "Archivieren",
"Archive (e)": "Archivieren (e)", "Archive (e)": "Archivieren (e)",
@@ -1076,6 +1078,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier ist nichts ungelesen", "Nothing unread here": "Hier ist nichts ungelesen",
}, },
plurals: { plurals: {
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" }, "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" }, "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" }, "{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
+3
View File
@@ -47,6 +47,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "Exportar archivo iCAL",
"Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Archivar", "Archive": "Archivar",
"Archive (e)": "Archivar (e)", "Archive (e)": "Archivar (e)",
@@ -1049,6 +1051,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Aquí no hay nada sin leer", "Nothing unread here": "Aquí no hay nada sin leer",
}, },
plurals: { plurals: {
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" }, "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" }, "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í" }, "{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
+3
View File
@@ -52,6 +52,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "Exporter un fichier iCAL",
"Could not export this calendar: {error}": "Impossible dexporter ce calendrier : {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Archiver", "Archive": "Archiver",
"Archive (e)": "Archiver (e)", "Archive (e)": "Archiver (e)",
@@ -1054,6 +1056,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Rien de non lu ici", "Nothing unread here": "Rien de non lu ici",
}, },
plurals: { 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" }, "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 dimporté", other: "Déjà présents : {n} événements, rien dimporté" }, "Already here: {n} events, nothing imported": { one: "Déjà présent : {n} événement, rien dimporté", other: "Déjà présents : {n} événements, rien dimporté" },
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" }, "{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
+3
View File
@@ -46,6 +46,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "iCAL ファイルをエクスポート",
"Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "アーカイブ", "Archive": "アーカイブ",
"Archive (e)": "アーカイブ (e)", "Archive (e)": "アーカイブ (e)",
@@ -1057,6 +1059,7 @@ export const catalog: Catalog = {
"Nothing unread here": "ここに未読はありません", "Nothing unread here": "ここに未読はありません",
}, },
plurals: { plurals: {
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
"Imported {n} events": { other: "{n} 件の予定をインポートしました" }, "Imported {n} events": { other: "{n} 件の予定をインポートしました" },
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" }, "Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
"{n} were already here": { other: "{n} 件はすでに存在していました" }, "{n} were already here": { other: "{n} 件はすでに存在していました" },
+3
View File
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "iCAL-bestand exporteren",
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Archiveren", "Archive": "Archiveren",
"Archive (e)": "Archiveren (e)", "Archive (e)": "Archiveren (e)",
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier is niets ongelezen", "Nothing unread here": "Hier is niets ongelezen",
}, },
plurals: { 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" }, "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" }, "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" }, "{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
+3
View File
@@ -50,6 +50,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "Exportar arquivo iCAL",
"Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Arquivar", "Archive": "Arquivar",
"Archive (e)": "Arquivar (e)", "Archive (e)": "Arquivar (e)",
@@ -1052,6 +1054,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Não há nada não lido aqui", "Nothing unread here": "Não há nada não lido aqui",
}, },
plurals: { plurals: {
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" }, "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" }, "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" }, "{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
+3
View File
@@ -49,6 +49,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "Экспортировать файл iCAL",
"Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Архивировать", "Archive": "Архивировать",
"Archive (e)": "Архивировать (e)", "Archive (e)": "Архивировать (e)",
@@ -1051,6 +1053,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Здесь нет непрочитанного", "Nothing unread here": "Здесь нет непрочитанного",
}, },
plurals: { plurals: {
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
"Imported {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} события, ничего не импортировано" }, "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} уже были здесь" }, "{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
+3
View File
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "Експортувати файл iCAL",
"Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "Архівувати", "Archive": "Архівувати",
"Archive (e)": "Архівувати (e)", "Archive (e)": "Архівувати (e)",
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Тут немає непрочитаного", "Nothing unread here": "Тут немає непрочитаного",
}, },
plurals: { plurals: {
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
"Imported {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} події, нічого не імпортовано" }, "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} уже були тут" }, "{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
+3
View File
@@ -45,6 +45,8 @@ import type { Catalog } from "@/lib/i18n";
*/ */
export const catalog: Catalog = { export const catalog: Catalog = {
strings: { strings: {
"Export iCAL file": "导出 iCAL 文件",
"Could not export this calendar: {error}": "无法导出此日历:{error}",
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
"Archive": "归档", "Archive": "归档",
"Archive (e)": "归档 (e)", "Archive (e)": "归档 (e)",
@@ -1056,6 +1058,7 @@ export const catalog: Catalog = {
"Nothing unread here": "这里没有未读邮件", "Nothing unread here": "这里没有未读邮件",
}, },
plurals: { plurals: {
"Exported {n} events": { other: "已导出 {n} 个日程" },
"Imported {n} events": { other: "已导入 {n} 个日程" }, "Imported {n} events": { other: "已导入 {n} 个日程" },
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" }, "Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
"{n} were already here": { 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/);
});
});
+34 -6
View File
@@ -5,7 +5,7 @@ import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browser
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { useContacts } from "./contacts"; import { useContacts } from "./contacts";
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays"; 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 { withBase } from "@/lib/basePath";
import { settings, useSettings } from "./settings"; import { settings, useSettings } from "./settings";
import { useSession } from "./session"; import { useSession } from "./session";
@@ -298,6 +298,8 @@ interface CalendarState {
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>; importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
/** Import a whole .ics file. Says how many it created, and how many were already here. */ /** 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 }>; 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; applyChanges(types: Set<string>): void;
invalidate(): void; invalidate(): void;
setDraft(draft: EventDraft | null): 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 * calendars, and `calendarIds` says which without relying on a filter this
* client has not confirmed the server supports. * client has not confirmed the server supports.
*/ */
async function uidsInCalendar(accountId: Id, calendarId: Id): Promise<Set<string>> { async function eventsInCalendar(accountId: Id, calendarId: Id, properties: string[]): Promise<CalendarEvent[]> {
const uids = new Set<string>(); const found: CalendarEvent[] = [];
const page = client.maxObjectsInGet; const page = client.maxObjectsInGet;
for (let position = 0; ; ) { for (let position = 0; ; ) {
const q = await client.call<QueryResponse>("CalendarEvent/query", { accountId, position, limit: page }); const q = await client.call<QueryResponse>("CalendarEvent/query", { accountId, position, limit: page });
const ids = q.ids ?? []; const ids = q.ids ?? [];
if (!ids.length) break; if (!ids.length) break;
for (const part of chunk(ids, page)) { for (const part of chunk(ids, page)) {
const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties: ["uid", "calendarIds"] }); const g = await client.call<GetResponse<CalendarEvent>>("CalendarEvent/get", { accountId, ids: part, properties });
for (const e of g.list) if (e.uid && e.calendarIds?.[calendarId]) uids.add(e.uid); for (const e of g.list) if (e.calendarIds?.[calendarId]) found.push(e);
} }
position += ids.length; position += ids.length;
// `total` is optional, so the empty page above is what actually ends this; // `total` is optional, so the empty page above is what actually ends this;
// this only saves the round trip that would find it. // this only saves the round trip that would find it.
if (q.total != null && position >= q.total) break; 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) => ({ export const useCalendar = create<CalendarState>((set, get) => ({
@@ -910,6 +918,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
return { created, skipped }; 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) { applyChanges(types) {
if (types.has("Calendar")) void get().loadCalendars(); if (types.has("Calendar")) void get().loadCalendars();
if (types.has("CalendarEvent")) get().invalidate(); if (types.has("CalendarEvent")) get().invalidate();
+24 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState, useEffect } from "react"; import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation } from "wouter"; 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 { 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";
@@ -41,6 +41,26 @@ export function CalendarSidebar() {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const importInto = useRef<Id | null>(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 importFile = async (file: File) => {
const calendarId = importInto.current; const calendarId = importInto.current;
if (!calendarId) return; if (!calendarId) return;
@@ -219,6 +239,9 @@ export function CalendarSidebar() {
fileRef.current?.click(); 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} /> <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