Files
skills/docs/api-stack/api/api-plugin-events/src/ics/ics-builder/ics-builder.md
2026-05-29 08:20:56 +01:00

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 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

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. 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.