5.9 KiB
ics-builder
(no submodules)
Purpose
The ics-builder module emits a complete RFC 5545 VCALENDAR document
for a single event document loaded from EventRepository. It is a
dependency-free, pure-function module so the same builder can be reused
by future surfaces (cron-driven email digests, signed feed URLs,
calendar-app push delivery) without dragging in route plumbing or a
third-party ical library.
The builder is the load-bearing piece of the GET /:id/ics endpoint
shipped in add-events-ics-export: every escape rule, line-fold
decision, and CRLF terminator the spec mandates lives in this module
and is unit-tested at the byte level. Routes only assemble inputs and
forward the resulting string.
Features
buildVCalendar({ event, attendees?, nowMs })— top-level builder. Emits oneBEGIN:VCALENDAR…END:VCALENDARblock containing oneVEVENT. Whenattendeesis provided, oneATTENDEEline is appended per row.escapeText(raw)— RFC 5545 §3.3.11 text escape (\\,\;,\,, CR/LF →\n).foldLine(line)— RFC 5545 §3.1 line folding at 75-octet boundaries. UTF-8 safe (pulls cut back to a start byte when needed).formatDateUtc(ms)— UTCYYYYMMDDTHHMMSSZform.mapStatus(intStatus)— event status integer → VEVENTSTATUSstring (0→TENTATIVE,1→CONFIRMED,2→CANCELLED).mapAttendeeStatus(rsvpStatus)→PARTSTATvalue (going→ACCEPTED,maybe→TENTATIVE,not_going→DECLINED,invited→NEEDS-ACTION).- CRLF (
\r\n) line endings throughout; every output line including the finalEND:VCALENDARterminates with CRLF.
Usage: Basic
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Quarterly review",
description: "Planning + retro.",
start_time: "2026-06-01T10:00:00Z",
end_time: "2026-06-01T11:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// → "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:...\r\nBEGIN:VEVENT\r\n..."
Usage: Advanced
Emit the ?with=rsvps variant by passing an attendee roster. The
builder maps each RSVP status to its PARTSTAT parameter and escapes
the display name per §3.3.11.
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Annual gala; tickets, mandatory",
start_time: "2026-12-31T18:00:00Z",
end_time: "2027-01-01T00:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 1, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
attendees: [
{
id: "event_rsvp:1",
user: "user:42",
user_name: "Ada, of Lovelace",
user_email: "ada@example.com",
status: "going",
},
{
id: "event_rsvp:2",
user: "user:43",
status: "maybe",
},
],
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// The first attendee renders as:
// ATTENDEE;PARTSTAT=ACCEPTED;CN=Ada\, of Lovelace:mailto:ada@example.com
// The second falls back to the non-routable @suaveplan.local domain:
// ATTENDEE;PARTSTAT=TENTATIVE:mailto:user:43@suaveplan.local
API Reference
buildVCalendar(input: BuildVCalendarInput): string— returns the full VCALENDAR document, terminated by CRLF. Throws nothing — malformed input (e.g. unparseablestart_time) produces a best-effortInvalid Date-shaped UTC stamp which is the responsibility of the calling route to detect and avoid.escapeText(raw: string): string— escapes backslash, semicolon, comma, and any CR/LF to\n(two literal characters:\+n).foldLine(line: string): string— folds long lines per §3.1. Returns the input unchanged when ≤75 octets.formatDateUtc(ms: number): string— UTC date-time per §3.3.5.mapStatus(status: number): IcsEventStatus— total function. Defaults toCONFIRMEDfor any status other than0or2.mapAttendeeStatus(status: string): IcsAttendeePartStat— total function. ReturnsNEEDS-ACTIONfor unknown /invitedvalues.
Implementation Notes
- No
icaldependency. The 75-octet fold + four escape rules are small enough to maintain locally; pulling a third-party module would add a transitive surface that has historically caused calendar-app interop bugs (escaping policy varies). The spec is RFC-pinned, not library-pinned. - UTC only. Events on the plugin are stored UTC per the existing service contract. VTIMEZONE blocks are a follow-up change and not needed until the plugin learns to store / surface a non-UTC tzid.
SEQUENCE:0. The event schema has norevision/versionfield. Until one ships, every export emitsSEQUENCE:0. The CALDAV / iCloud conformance rule of "bump SEQUENCE on every mutation" therefore has nothing to do here; the constant is forward-safe and is preferred to omission.- UTF-8 fold safety. When the 75-octet boundary lands inside a multi-byte sequence, the cut is pulled back to the previous start byte. The maximum reduction is 3 octets (the longest BMP continuation). Pure-ASCII input — the common case — folds at exactly 75 octets.
ORGANIZERnot emitted. The event schema has no host email field. The omission is documented in the proposal so a future schema migration can plug the value in without re-litigating the decision.