Escape and fold the iCal export, serve it as text/calendar, add subscription links
- 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>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
|
||||
import {Event} from '../../src/models/calendar/events/event.interface.js';
|
||||
|
||||
/**
|
||||
* The iCal export is read by every subscribed calendar app, so a malformed
|
||||
* file is an outage nobody on our side sees. These pin RFC 5545's text
|
||||
* escaping and line folding, which the generator did not do before: a line
|
||||
* break in a description ended the DESCRIPTION property early, and the new
|
||||
* calendar frontend's multi-line description field would have produced
|
||||
* exactly that.
|
||||
*/
|
||||
|
||||
const event = (overrides: Partial<Event> = {}): Event => ({
|
||||
eventId: 1,
|
||||
calendarId: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Konzert',
|
||||
description: '',
|
||||
startDateTime: new Date('2026-10-03T17:00:00Z'),
|
||||
endDateTime: new Date('2026-10-03T19:00:00Z'),
|
||||
createdDate: new Date('2026-01-01T12:00:00Z'),
|
||||
location: '',
|
||||
createdBy: 'Anna',
|
||||
url: '',
|
||||
wholeDay: false,
|
||||
repeatFrequency: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
|
||||
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
|
||||
|
||||
describe('escapeText', () => {
|
||||
it('escapes backslash, semicolon, comma and line breaks', () => {
|
||||
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
|
||||
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
|
||||
});
|
||||
|
||||
it('escapes the backslash first, so the others are not doubled', () => {
|
||||
expect(escapeText(';')).toBe('\\;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldLine', () => {
|
||||
it('leaves a short line alone', () => {
|
||||
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
|
||||
});
|
||||
|
||||
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
|
||||
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
|
||||
const folded = foldLine(line);
|
||||
for (const physical of folded.split('\r\n')) {
|
||||
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||
}
|
||||
expect(folded.replace(/\r\n /g, '')).toBe(line);
|
||||
});
|
||||
|
||||
it('never splits a multi-byte character', () => {
|
||||
const line = 'SUMMARY:' + 'ü'.repeat(80);
|
||||
for (const physical of foldLine(line).split('\r\n')) {
|
||||
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
|
||||
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToIcal', () => {
|
||||
it('terminates every line with CRLF', async () => {
|
||||
const ical = await convertToIcal([event()]);
|
||||
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
|
||||
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
|
||||
const lines = unfold(ical);
|
||||
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
|
||||
// Nothing leaked out as a line of its own.
|
||||
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
|
||||
});
|
||||
|
||||
it('escapes the title and the location too', async () => {
|
||||
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
|
||||
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
|
||||
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
|
||||
});
|
||||
|
||||
it('does not escape the URL, but keeps it on one line', async () => {
|
||||
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
|
||||
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
|
||||
});
|
||||
|
||||
it('still writes the yearly rule for birthdays', async () => {
|
||||
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
|
||||
expect(lines).toContain('RRULE:FREQ=YEARLY');
|
||||
});
|
||||
|
||||
it('leaves the event\'s own dates untouched', async () => {
|
||||
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
|
||||
const whole = event({
|
||||
wholeDay: true,
|
||||
startDateTime: new Date('2026-10-03T00:00:00'),
|
||||
endDateTime: new Date('2026-10-03T23:59:00')
|
||||
});
|
||||
const endBefore = whole.endDateTime.getTime();
|
||||
await convertToIcal([whole]);
|
||||
expect(whole.endDateTime.getTime()).toBe(endBefore);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user