a574e93359
- iCal export: RFC 5545 text escaping for SUMMARY, DESCRIPTION and LOCATION,
line folding at 75 octets (UTF-8 aware), CRLF line endings, and
Content-Type text/calendar instead of the inferred text/html. A line break
in a description used to end the property early; the new calendar
frontend has a multi-line description field.
- formatDate no longer shifts the event's end date in place.
- GET /calendar/events/subscriptions: the iCal URL of every calendar, with
its shared password where needed, for the calendar app's "Abonnieren"
dialog. Editors only (requireAppAccess('calendar')), the shared password
is not accepted there, never cached; calendars without a configured
credential are left out.
- Tests for both; documented in docs/calendar-ical.md.
Backwards compatible with the current Angular calendar app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 lines
3.4 KiB
Markdown
66 lines
3.4 KiB
Markdown
# Calendar iCal export and subscriptions
|
|
|
|
How `GET /calendar/events/{calendar}/ical` builds its file, what changed on 2026-09-28, and the
|
|
endpoint the calendar app uses to hand editors ready-made subscription URLs.
|
|
|
|
## The export
|
|
|
|
`icalgenerator.service.ts` turns a calendar's `PUBLIC` events into one `VCALENDAR`:
|
|
|
|
- Timed events: `DTSTART;TZID=Europe/Berlin:` / `DTEND;TZID=Europe/Berlin:` formatted with the
|
|
**API process's local** `getHours()` etc. The times are therefore only right if the process runs
|
|
in Berlin time (`TZ=Europe/Berlin`, or a server in that zone). Nothing enforces this. Inferred
|
|
from the code; not checked against the production server.
|
|
- Whole-day events: `VALUE=DATE`, and the stored end date is treated as the *inclusive* last day -
|
|
the generator adds one day for `DTEND`. The calendar app stores whole-day events as 00:00 to
|
|
23:59 Berlin time for this reason.
|
|
- `repeatFrequency` is written verbatim as `RRULE:FREQ=<value>` (no interval, count or end).
|
|
- `public` needs no credential; the other calendars take `?password=` because a calendar client
|
|
cannot send the session cookie (see `calendar-auth-migration.md`).
|
|
|
|
## RFC 5545 conformance (2026-09-28)
|
|
|
|
Before this date the generator wrote text values raw. A line break in a description ended the
|
|
`DESCRIPTION` property early and the rest was read as a garbage property; unescaped `,` and `;`
|
|
are invalid in text values. The new calendar frontend has a multi-line description field, so this
|
|
went from theoretical to certain. Now:
|
|
|
|
- `SUMMARY`, `DESCRIPTION` and `LOCATION` go through `escapeText` (`\` `;` `,` and line breaks,
|
|
backslash first).
|
|
- `URL` is a URI value and is not escaped; line breaks are stripped from it.
|
|
- Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split)
|
|
and the file uses CRLF line endings throughout (`toContentLines`).
|
|
- `formatDate` no longer shifts the caller's `Date` when it adds the whole-day end's extra day.
|
|
- The response is `Content-Type: text/calendar; charset=utf-8`. Before, Express inferred
|
|
`text/html` from the string body; calendar apps coped, but it was wrong.
|
|
|
|
Pinned by `test/calendar/icalgenerator.service.test.ts`.
|
|
|
|
Not changed, worth knowing: `ORGANIZER` is written as a bare display name (`ORGANIZER:Anna`),
|
|
while RFC 5545 expects a cal-address (`ORGANIZER;CN=Anna:mailto:…`). Calendar apps have tolerated
|
|
it so far.
|
|
|
|
## `GET /calendar/events/subscriptions`
|
|
|
|
For the calendar app's "Abonnieren" dialog. Behind `requireAppAccess('calendar')`; the shared
|
|
password is **not** accepted here (one calendar's password must not reveal the others).
|
|
|
|
Returns `[{calendar, icalUrl}]` in `calendarNames` order, built from `API_BASE_URL`:
|
|
|
|
- `public` without a password;
|
|
- every other calendar with `?password=<its shared password>`, URL-encoded;
|
|
- a calendar whose credential env var is unset is left out rather than listed with a URL that
|
|
cannot work.
|
|
|
|
`choir` and `birthdays` share `CHOIR_CREDENTIAL`, so rotating it changes both URLs - and breaks
|
|
every existing subscription to either. The response is sent with `Cache-Control: no-store`
|
|
because it carries the passwords.
|
|
|
|
Signed-in editors can read every event of every calendar anyway, so handing them the passwords
|
|
exposes nothing they could not already see.
|
|
|
|
## Found while doing this
|
|
|
|
- Only the admin pool reads `DB_PORT`; `Calendar.db.ts` (and the feedback/tickets pools) always
|
|
connect to 3306. A local test database for the calendar has to listen there.
|