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
* read-only and redrawn from scratch on every refresh, so nothing here has to
@@ -13,6 +18,8 @@
* would be worse than one that shows the first occurrence and says so.
*/
import type { JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule } from "@/jmap/types";
export interface IcsEvent {
uid: string;
summary: string;
@@ -259,3 +266,217 @@ function finish(e: Partial<IcsEvent> & { dtend?: Date; duration?: number }): Ics
recurring: Boolean(e.recurring),
};
}
/* ------------------------------------------------------------------ */
/* Writing */
/* ------------------------------------------------------------------ */
/**
* JSCalendar out to iCalendar.
*
* The reverse of everything above, and a narrower job than it looks: the events
* come from the server as RFC 8984 objects, and RFC 8984 was written as a
* restatement of RFC 5545, so most of this is renaming. Where the two disagree
* the comments say which way it went and why.
*
* What is deliberately not here, stated rather than discovered:
*
* - **No VTIMEZONE components.** A `TZID` is emitted with the IANA name the
* server holds -- "Europe/Berlin" -- and no definition of that zone beside
* it. Generating one means shipping a zone database to the browser to
* describe rules the reader's own system already knows. Every client that
* matters resolves IANA names; a strict validator will complain, and the
* alternative -- converting everything to UTC -- would be worse, because a
* weekly 09:00 that becomes 08:00 for half the year is a wrong calendar
* rather than a pedantic one.
* - **Overrides are applied at the top level only.** A recurrence override is a
* JSON patch, and a patch addressing `locations/x/name` is not something this
* flattens; those paths are left on the master's value. Plain overridden
* properties -- a moved time, a changed title -- come across.
* - **No localizations, no relatedTo, no per-participant delegation.** Nothing
* in ihasmail sets them.
*/
export function toIcs(events: JSCalendarEvent[], calendarName?: string): string {
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//ihasmail//EN", "CALSCALE:GREGORIAN"];
if (calendarName) lines.push(`X-WR-CALNAME:${escText(calendarName)}`);
for (const e of events) lines.push(...vevent(e));
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
/** RFC 5545 escaping. A comma and a semicolon separate values, so both go. */
function escText(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");
}
/** 75 octets is the limit; a continuation begins with one space. */
function foldLine(line: string): string {
if (line.length <= 75) return line;
const out: string[] = [];
let i = 0;
while (i < line.length) {
out.push((i ? " " : "") + line.slice(i, i + 74));
i += 74;
}
return out.join("\r\n");
}
/** "2026-09-02T09:00:00" -> "20260902T090000"; the date half alone for all-day. */
function stamp(local: string, dateOnly = false): string {
const compact = local.replace(/[-:]/g, "").replace(/\.\d+/, "");
return dateOnly ? compact.slice(0, 8) : compact.slice(0, 15);
}
/** A UTC instant as iCalendar spells it. */
function utcStamp(iso: string): string {
return `${iso.replace(/[-:]/g, "").replace(/\.\d+/, "").slice(0, 15)}Z`;
}
/**
* A date-time property with its zone said the way the zone requires.
*
* Three shapes, and the difference matters: a floating time carries no zone and
* means "whatever clock the reader is on", UTC carries the Z, and everything
* else names an IANA zone in TZID.
*/
function dateProp(name: string, local: string, timeZone: string | null | undefined, allDay: boolean): string {
if (allDay) return `${name};VALUE=DATE:${stamp(local, true)}`;
if (!timeZone) return `${name}:${stamp(local)}`;
if (timeZone === "Etc/UTC" || timeZone === "UTC") return `${name}:${stamp(local)}Z`;
return `${name};TZID=${timeZone}:${stamp(local)}`;
}
const STATUS: Record<string, string> = { confirmed: "CONFIRMED", cancelled: "CANCELLED", tentative: "TENTATIVE" };
const CLASS: Record<string, string> = { public: "PUBLIC", private: "PRIVATE", secret: "CONFIDENTIAL" };
const PARTSTAT: Record<string, string> = {
"needs-action": "NEEDS-ACTION", accepted: "ACCEPTED", declined: "DECLINED",
tentative: "TENTATIVE", delegated: "DELEGATED",
};
/** A participant's address, wherever this server keeps it. */
function participantAddress(p: JSCalendarParticipant): string | null {
return p.calendarAddress ?? p.sendTo?.imip ?? (p.email ? `mailto:${p.email}` : null) ?? null;
}
function vevent(e: JSCalendarEvent, recurrenceId?: { local: string; timeZone: string | null | undefined; allDay: boolean }): string[] {
const allDay = Boolean(e.showWithoutTime);
const tz = allDay ? null : e.timeZone;
const out = ["BEGIN:VEVENT", `UID:${e.uid}`];
/* DTSTAMP is required and means "when this description was made", which for
an export is the last time the event changed. */
out.push(`DTSTAMP:${utcStamp(e.updated ?? e.created ?? new Date().toISOString())}`);
out.push(dateProp("DTSTART", e.start, tz, allDay));
/* DURATION rather than DTEND, because that is what JSCalendar holds and
converting would mean doing the zone arithmetic here to no purpose. */
if (e.duration && e.duration !== "PT0S") out.push(`DURATION:${e.duration}`);
if (recurrenceId) out.push(dateProp("RECURRENCE-ID", recurrenceId.local, recurrenceId.timeZone, recurrenceId.allDay));
if (e.title) out.push(`SUMMARY:${escText(e.title)}`);
if (e.description) out.push(`DESCRIPTION:${escText(e.description)}`);
const location = Object.values(e.locations ?? {}).map((l) => l.name).filter(Boolean)[0];
if (location) out.push(`LOCATION:${escText(location)}`);
/* A virtual location is a URL and belongs in URL, not LOCATION: putting a
video link where a room name goes is what makes an agenda unreadable. */
const virtual = Object.values(e.virtualLocations ?? {}).map((v) => v.uri).filter(Boolean)[0];
const link = Object.values(e.links ?? {}).map((l) => l.href).filter(Boolean)[0];
if (virtual ?? link) out.push(`URL:${virtual ?? link}`);
const categories = [...Object.keys(e.keywords ?? {}), ...Object.keys(e.categories ?? {})];
if (categories.length) out.push(`CATEGORIES:${categories.map(escText).join(",")}`);
if (e.status && STATUS[e.status]) out.push(`STATUS:${STATUS[e.status]}`);
if (e.privacy && CLASS[e.privacy]) out.push(`CLASS:${CLASS[e.privacy]}`);
/* TRANSP is about whether the time is busy, which is the same question
freeBusyStatus answers and the opposite word for it. */
if (e.freeBusyStatus) out.push(`TRANSP:${e.freeBusyStatus === "free" ? "TRANSPARENT" : "OPAQUE"}`);
if (e.priority != null) out.push(`PRIORITY:${e.priority}`);
if (e.sequence != null) out.push(`SEQUENCE:${e.sequence}`);
if (e.created) out.push(`CREATED:${utcStamp(e.created)}`);
if (e.updated) out.push(`LAST-MODIFIED:${utcStamp(e.updated)}`);
if (e.color) out.push(`COLOR:${e.color}`);
const organizer = e.organizerCalendarAddress ?? e.replyTo?.imip;
if (organizer) out.push(`ORGANIZER:${organizer}`);
for (const p of Object.values(e.participants ?? {})) {
const address = participantAddress(p);
if (!address) continue;
const params = [
p.name ? `CN=${escText(p.name)}` : "",
p.participationStatus && PARTSTAT[p.participationStatus] ? `PARTSTAT=${PARTSTAT[p.participationStatus]}` : "",
p.roles?.chair ? "ROLE=CHAIR" : p.roles?.optional ? "ROLE=OPT-PARTICIPANT" : "",
p.expectReply ? "RSVP=TRUE" : "",
].filter(Boolean);
out.push(`ATTENDEE${params.length ? `;${params.join(";")}` : ""}:${address}`);
}
/* Stalwart 0.16 names a single rule `recurrenceRule`; RFC 8984 says
`recurrenceRules`. Both are read, because both turn up. */
for (const rule of [...(e.recurrenceRules ?? []), ...(e.recurrenceRule ? [e.recurrenceRule] : [])]) {
out.push(`RRULE:${rrule(rule, allDay)}`);
}
const excluded: string[] = [];
const modified: Array<[string, Record<string, unknown>]> = [];
for (const [when, patch] of Object.entries(e.recurrenceOverrides ?? {})) {
if (patch === null || (patch as Record<string, unknown>).excluded === true) excluded.push(when);
else modified.push([when, patch as Record<string, unknown>]);
}
if (excluded.length) {
out.push(allDay
? `EXDATE;VALUE=DATE:${excluded.map((d) => stamp(d, true)).join(",")}`
: tz
? `EXDATE;TZID=${tz}:${excluded.map((d) => stamp(d)).join(",")}`
: `EXDATE:${excluded.map((d) => stamp(d)).join(",")}`);
}
/*
* An alarm is a component, not a property, so it nests inside the event. Only
* DISPLAY and EMAIL are written because they are the only two JSCalendar
* names, and an acknowledged alert is still exported -- whether it has fired
* is this reader's business, not the file's.
*/
for (const a of Object.values(e.alerts ?? {})) {
const trigger = "offset" in a.trigger
? `TRIGGER${a.trigger.relativeTo === "end" ? ";RELATED=END" : ""}:${a.trigger.offset}`
: `TRIGGER;VALUE=DATE-TIME:${utcStamp(a.trigger.when)}`;
out.push("BEGIN:VALARM", trigger, `ACTION:${a.action === "email" ? "EMAIL" : "DISPLAY"}`, `DESCRIPTION:${escText(e.title ?? "")}`, "END:VALARM");
}
out.push("END:VEVENT");
/* A changed occurrence is its own VEVENT carrying the same UID and the
RECURRENCE-ID of the slot it replaces -- which is how iCalendar has always
said it, and why these come after the master rather than inside it. */
for (const [when, patch] of modified) {
const merged = { ...e, ...patch } as JSCalendarEvent;
delete merged.recurrenceRules;
delete merged.recurrenceRule;
delete merged.recurrenceOverrides;
out.push(...vevent(merged, { local: when, timeZone: tz, allDay }));
}
return out;
}
const FREQ: Record<string, string> = {
yearly: "YEARLY", monthly: "MONTHLY", weekly: "WEEKLY", daily: "DAILY",
hourly: "HOURLY", minutely: "MINUTELY", secondly: "SECONDLY",
};
const DAYS: Record<string, string> = { mo: "MO", tu: "TU", we: "WE", th: "TH", fr: "FR", sa: "SA", su: "SU" };
function rrule(r: JSCalendarRecurrenceRule, allDay: boolean): string {
const parts = [`FREQ=${FREQ[r.frequency] ?? r.frequency.toUpperCase()}`];
if (r.interval && r.interval !== 1) parts.push(`INTERVAL=${r.interval}`);
if (r.count != null) parts.push(`COUNT=${r.count}`);
/* UNTIL has to match DTSTART's kind: a date for an all-day series, and a UTC
instant otherwise. Sending a local time here is the classic way to make a
series stop on the wrong day in another zone. */
if (r.until) parts.push(`UNTIL=${allDay ? stamp(r.until, true) : `${stamp(r.until)}Z`}`);
if (r.byDay?.length) parts.push(`BYDAY=${r.byDay.map((d) => `${d.nthOfPeriod ?? ""}${DAYS[d.day] ?? d.day.toUpperCase()}`).join(",")}`);
if (r.byMonthDay?.length) parts.push(`BYMONTHDAY=${r.byMonthDay.join(",")}`);
if (r.byMonth?.length) parts.push(`BYMONTH=${r.byMonth.join(",")}`);
if (r.byYearDay?.length) parts.push(`BYYEARDAY=${r.byYearDay.join(",")}`);
if (r.byWeekNo?.length) parts.push(`BYWEEKNO=${r.byWeekNo.join(",")}`);
if (r.byHour?.length) parts.push(`BYHOUR=${r.byHour.join(",")}`);
if (r.byMinute?.length) parts.push(`BYMINUTE=${r.byMinute.join(",")}`);
if (r.bySecond?.length) parts.push(`BYSECOND=${r.bySecond.join(",")}`);
if (r.bySetPosition?.length) parts.push(`BYSETPOS=${r.bySetPosition.join(",")}`);
if (r.firstDayOfWeek) parts.push(`WKST=${DAYS[r.firstDayOfWeek] ?? r.firstDayOfWeek.toUpperCase()}`);
return parts.join(";");
}