first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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