first commit
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# ics-builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user