import { pruefeNow, pruefeText, zeitMs } from './util.js' /** * Kalenderdateien des Terminplaners (iCalendar nach RFC 5545, Terminmitteilungen nach RFC 5546). * Eigener kleiner Schreiber ohne Abhaengigkeit; Zeiten immer in UTC (`...Z`), damit keine VTIMEZONE noetig ist. * * - Zeilen enden mit CRLF und werden bei 75 Oktetten (UTF-8-Bytes, nie mitten in einem Zeichen) gefaltet. * - TEXT-Werte (Titel, Ort, Beschreibung, Kalendername): `\`, `;`, `,` und Zeilenumbrueche werden maskiert, damit ein * Wert nie eine neue Eigenschaft beginnen kann. Alles andere (UID, E-Mail, CN) verliert CR/LF und Steuerzeichen. * - Pure Funktionen: kein config, keine Uhr; `jetzt` (DTSTAMP) kommt vom Aufrufer. * * UID: stabil je Buchung (`@termine.webklar.com`), SEQUENCE steigt bei jeder Aenderung des Termins. */ const PRODID = '-//WEBklar//Terminplaner//DE' const METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH'] const encoder = new TextEncoder() // Steuerzeichen ausser Tab (und LF/CR, die je nach Feld maskiert oder entfernt werden) const STEUER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g const sauber = (wert) => String(wert ?? '').toWellFormed() /** Wert ohne CR/LF und Steuerzeichen (UID, Parameter, E-Mail). */ const einzeilig = (wert) => sauber(wert).replace(/[\r\n]/g, '').replace(STEUER, '') /** TEXT nach RFC 5545 3.3.11: Zeilenumbrueche werden zu `\n`, `\`, `;` und `,` bekommen einen Backslash. */ const text = (wert) => sauber(wert) .replace(STEUER, '') .replace(/[\\;,]/g, '\\$&') .replace(/\r\n|[\r\n]/g, '\\n') /** Parameterwert in Anfuehrungszeichen (CN): ohne `"`, CR/LF und Steuerzeichen. */ const parameter = (wert) => einzeilig(wert).replace(/"/g, '') /** mailto-Adresse: genau ein Postfach, ohne Leerraum und Trennzeichen. */ const adresse = (wert) => einzeilig(wert).replace(/[\s<>",;\\]/g, '') /** Zeitpunkt als `20261005T080000Z` (Sekundenbruchteile fallen weg). */ const utc = (ms) => new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') /** Eine logische Zeile bei 75 Oktetten falten; Folgezeilen beginnen mit einem Leerzeichen (zaehlt als Oktett). */ function falte(zeile) { if (encoder.encode(zeile).length <= 75) return zeile const teile = [] let aktuell = '' let oktette = 0 for (const zeichen of zeile) { const n = encoder.encode(zeichen).length if (oktette + n > 75) { teile.push(aktuell) aktuell = ' ' oktette = 1 } aktuell += zeichen oktette += n } teile.push(aktuell) return teile.join('\r\n') } function pruefeZeit(wert, name) { const ms = zeitMs(wert) if (!Number.isFinite(ms)) throw new TypeError(`${name} ist kein gueltiger Zeitpunkt`) return ms } /** `;CN="Name"` (nur mit Name) und `mailto:`-Adresse einer Person; ohne Adresse kein gueltiger Eintrag. */ function person(p, feld) { const email = adresse(p?.email) pruefeText(email, `${feld}.email`) const name = parameter(p.name) return { cn: name ? `;CN="${name}"` : '', mailto: `mailto:${email}` } } /** Zeilen eines VEVENT. `methode` entscheidet ueber ATTENDEE (nicht bei PUBLISH) und STATUS. */ function veranstaltung(t, methode, jetztMs) { pruefeText(einzeilig(t.uid), 'uid') pruefeText(text(t.titel), 'titel') const sequenz = t.sequenz ?? 0 if (!Number.isInteger(sequenz) || sequenz < 0) throw new TypeError('sequenz ist keine ganze Zahl ab 0') const start = pruefeZeit(t.start, 'start') const ende = pruefeZeit(t.ende, 'ende') if (ende <= start) throw new TypeError('ende liegt nicht nach start') const org = t.organisator if (!org && methode !== 'PUBLISH') throw new TypeError(`organisator fehlt (${methode} braucht den Organisator)`) const zeilen = ['BEGIN:VEVENT', `UID:${einzeilig(t.uid)}`, `DTSTAMP:${utc(jetztMs)}`, `SEQUENCE:${sequenz}`, `DTSTART:${utc(start)}`, `DTEND:${utc(ende)}`, `SUMMARY:${text(t.titel)}`] if (text(t.beschreibung)) zeilen.push(`DESCRIPTION:${text(t.beschreibung)}`) if (text(t.ort)) zeilen.push(`LOCATION:${text(t.ort)}`) if (org) { const o = person(org, 'organisator') zeilen.push(`ORGANIZER${o.cn}:${o.mailto}`) } // RFC 5546: PUBLISH enthaelt keine Teilnehmer (Betriebsmail und Abo tragen keine Gaeste ein) if (t.teilnehmer && methode !== 'PUBLISH') { const g = person(t.teilnehmer, 'teilnehmer') const partstat = methode === 'CANCEL' ? 'DECLINED' : 'ACCEPTED' zeilen.push(`ATTENDEE${g.cn};ROLE=REQ-PARTICIPANT;PARTSTAT=${partstat};RSVP=FALSE:${g.mailto}`) } zeilen.push(`STATUS:${methode === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`, 'TRANSP:OPAQUE', 'END:VEVENT') return zeilen } function kalender(methode, termine, jetzt, kopf = []) { const jetztMs = pruefeNow(jetzt, 'jetzt') const zeilen = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${PRODID}`, 'CALSCALE:GREGORIAN', `METHOD:${methode}`, ...kopf] for (const t of termine) zeilen.push(...veranstaltung(t, methode, jetztMs)) zeilen.push('END:VCALENDAR') return `${zeilen.map(falte).join('\r\n')}\r\n` } /** * Kalenderdatei mit genau einem Termin (Anhang einer Mail). * @param {{ uid: string, sequenz?: number, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start: Date|string|number, * ende: Date|string|number, titel: string, ort?: string, beschreibung?: string, * organisator: { name: string, email: string }, teilnehmer?: { name: string, email: string } | null, jetzt: Date }} termin * `methode` CANCEL setzt STATUS:CANCELLED (gleiche UID, hoehere SEQUENCE); PUBLISH traegt keinen ATTENDEE. * @returns {string} CRLF-getrennt, mit abschliessendem CRLF */ export function icsTermin(termin) { if (!METHODEN.includes(termin?.methode)) throw new TypeError(`methode muss ${METHODEN.join(', ')} sein`) return kalender(termin.methode, [termin], termin.jetzt) } /** * Kalender-Abo (METHOD:PUBLISH, mehrere Termine, ohne Gaeste). Jeder Eintrag wie bei icsTermin (`methode` und * `teilnehmer` werden ignoriert; `organisator` ist hier freiwillig). * @param {object[]} termine * @param {{ name: string, jetzt: Date }} optionen */ export function icsKalender(termine, { name, jetzt }) { return kalender('PUBLISH', termine, jetzt, [...(text(name) ? [`X-WR-CALNAME:${text(name)}`] : []), 'X-PUBLISHED-TTL:PT15M']) }