diff --git a/web/src/lib/__tests__/icsWrite.test.ts b/web/src/lib/__tests__/icsWrite.test.ts new file mode 100644 index 0000000..c677747 --- /dev/null +++ b/web/src/lib/__tests__/icsWrite.test.ts @@ -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: "kickoff@example.org", 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:kickoff@example.org")).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:chair@example.org", + participants: { + p1: { roles: { attendee: true }, name: "Ada", calendarAddress: "mailto:ada@example.org", participationStatus: "accepted" as const, expectReply: true }, + p2: { roles: { optional: true }, sendTo: { imip: "mailto:alan@example.org" }, participationStatus: "needs-action" as const }, + }, + } as JSCalendarEvent; + expect(one(e, "ORGANIZER")).toBe("ORGANIZER:mailto:chair@example.org"); + const att = find([e], "ATTENDEE"); + expect(att[0]).toBe("ATTENDEE;CN=Ada;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:ada@example.org"); + expect(att[1]).toBe("ATTENDEE;PARTSTAT=NEEDS-ACTION;ROLE=OPT-PARTICIPANT:mailto:alan@example.org"); + }); + + 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: "retro@example.org", title: "Retro", start: "2026-09-09T14:00:00" }]; + const back = parseIcs(toIcs(two)); + expect(back.events.map((e) => e.uid)).toEqual(["kickoff@example.org", "retro@example.org"]); + 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"); + }); +}); diff --git a/web/src/lib/ics.ts b/web/src/lib/ics.ts index e2304f2..503fce4 100644 --- a/web/src/lib/ics.ts +++ b/web/src/lib/ics.ts @@ -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 & { 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 = { confirmed: "CONFIRMED", cancelled: "CANCELLED", tentative: "TENTATIVE" }; +const CLASS: Record = { public: "PUBLIC", private: "PRIVATE", secret: "CONFIDENTIAL" }; +const PARTSTAT: Record = { + "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]> = []; + for (const [when, patch] of Object.entries(e.recurrenceOverrides ?? {})) { + if (patch === null || (patch as Record).excluded === true) excluded.push(when); + else modified.push([when, patch as Record]); + } + 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 = { + yearly: "YEARLY", monthly: "MONTHLY", weekly: "WEEKLY", daily: "DAILY", + hourly: "HOURLY", minutely: "MINUTELY", secondly: "SECONDLY", +}; +const DAYS: Record = { 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(";"); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 44f9a69..2e6fde3 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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" }, diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 2d96789..b97f057 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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í" }, diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index d0deea0..6e19aed 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -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" }, diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index ccbd6ee..5952e17 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -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} 件はすでに存在していました" }, diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index a7323fa..bfc9028 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -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" }, diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 0f9b62e..a76083e 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -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" }, diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index c435b66..2b1e44e 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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} уже были здесь" }, diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index d167333..928ea95 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -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} уже були тут" }, diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index edcba5f..7687aaf 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -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} 个已存在" }, diff --git a/web/src/store/__tests__/ics-export.test.ts b/web/src/store/__tests__/ics-export.test.ts new file mode 100644 index 0000000..507f778 --- /dev/null +++ b/web/src/store/__tests__/ics-export.test.ts @@ -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 & { 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][] }; + 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 & { 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/); + }); +}); diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index a137809..45ea5f0 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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, calendarId: Id): Promise; /** 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): void; invalidate(): void; setDraft(draft: EventDraft | null): void; @@ -346,23 +348,29 @@ function forImport(event: Partial): Partial { * 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> { - const uids = new Set(); +async function eventsInCalendar(accountId: Id, calendarId: Id, properties: string[]): Promise { + const found: CalendarEvent[] = []; const page = client.maxObjectsInGet; for (let position = 0; ; ) { const q = await client.call("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>("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>("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> { + const events = await eventsInCalendar(accountId, calendarId, ["uid", "calendarIds"]); + return new Set(events.map((e) => e.uid).filter(Boolean)); } export const useCalendar = create((set, get) => ({ @@ -910,6 +918,26 @@ export const useCalendar = create((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(); diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 499d8e1..546d26a 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -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(null); const importInto = useRef(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. */} + } label={t("Export iCAL file")} onClick={() => void exportFile(menuCal)} /> } 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