# 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 one `BEGIN:VCALENDAR` … `END:VCALENDAR` block containing one `VEVENT`. When `attendees` is provided, one `ATTENDEE` line 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)` — UTC `YYYYMMDDTHHMMSSZ` form. - `mapStatus(intStatus)` — event status integer → VEVENT `STATUS` string (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`). - `mapAttendeeStatus(rsvpStatus)` → `PARTSTAT` value (`going`→ `ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`, `invited`→`NEEDS-ACTION`). - CRLF (`\r\n`) line endings throughout; every output line including the final `END:VCALENDAR` terminates with CRLF. ## Usage: Basic ```ts 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. ```ts 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. unparseable `start_time`) produces a best-effort `Invalid 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 to `CONFIRMED` for any status other than `0` or `2`. - `mapAttendeeStatus(status: string): IcsAttendeePartStat` — total function. Returns `NEEDS-ACTION` for unknown / `invited` values. ## Implementation Notes - **No `ical` dependency.** 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 no `revision` / `version` field. Until one ships, every export emits `SEQUENCE: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. - **`ORGANIZER` not 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.