import { heute as berlinHeute } from '../termine/zeit.js' import { istTag, naechte, plusTage } from './datum.js' import { config } from '../../config.js' /** * Buchungskalender: iCalendar lesen und schreiben (reine Funktionen, keine I/O). Gelesen werden nur VEVENT mit Anfangs- und * Enddatum (DATE, lokale Zeit, TZID oder UTC); mehr braucht die Belegung nicht. Ergebnis sind Berliner Kalendertage [von, bis) * wie bei allen Sperren. Wiederholungsregeln (RRULE) werden nicht aufgeloest: Booking.com und Airbnb liefern Einzeltermine. */ const MAX_EREIGNISSE = 2000 const MAX_NAECHTE = 731 /** Zeilenumbrueche entfalten: eine Zeile, die mit Leerzeichen oder Tab beginnt, setzt die vorige fort. */ function entfalte(text) { const out = [] for (const z of text.split(/\r\n|\n|\r/)) { if ((z.startsWith(' ') || z.startsWith('\t')) && out.length) out[out.length - 1] += z.slice(1) else out.push(z) } return out } /** 'NAME;PARAM=x:Wert' => { name (gross), params, wert }. Doppelpunkte in Anfuehrungszeichen der Parameter zaehlen nicht. */ function zeile(z) { let inAnf = false let doppelpunkt = -1 for (let i = 0; i < z.length; i++) { const c = z[i] if (c === '"') inAnf = !inAnf else if (c === ':' && !inAnf) { doppelpunkt = i break } } if (doppelpunkt < 1) return null const kopf = z.slice(0, doppelpunkt).split(';') const params = {} for (const p of kopf.slice(1)) { const i = p.indexOf('=') if (i > 0) params[p.slice(0, i).toUpperCase()] = p.slice(i + 1).replace(/^"|"$/g, '') } return { name: kopf[0].trim().toUpperCase(), params, wert: z.slice(doppelpunkt + 1).trim() } } const DATUM_RE = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/ /** iCal-Datum oder -Zeitpunkt => Berliner Kalendertag oder null. UTC (Z) wird nach Berlin umgerechnet, alles andere gilt als lokal. */ function tagVon(wert) { const m = DATUM_RE.exec(wert) if (!m) return null const [, j, mo, t, h, mi, s, z] = m if (z && h !== undefined) { const d = new Date(Date.UTC(+j, +mo - 1, +t, +h, +mi, +(s ?? 0))) return Number.isNaN(d.getTime()) ? null : berlinHeute(d) } const tag = `${j}-${mo}-${t}` return istTag(tag) ? tag : null } function dauerTage(wert) { const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T.*)?$/.exec(wert) if (!m) return null const tage = (m[1] ? +m[1] * 7 : 0) + (m[2] ? +m[2] : 0) return tage > 0 ? tage : null } /** * ICS-Text lesen. Ereignisse mit STATUS:CANCELLED, ohne lesbaren Anfang, die vor gestern enden oder nach dem Horizont beginnen * fallen weg. Fehlt das Ende, ist es eine Nacht. Kein VCALENDAR (z. B. HTML-Fehlerseite) => Error('kein_ics'). * @param {string} text * @param {{ heute?: string, horizontTage?: number }} [opt] heute: Berliner Tag (Vorgabe: jetzt); horizontTage: Vorgabe 31 x horizontMonate */ export function parseIcs(text, { heute = berlinHeute(), horizontTage = config.unterkunft.horizontMonate * 31 } = {}) { if (typeof text !== 'string' || !/^\s*BEGIN:VCALENDAR/i.test(text.replace(/^\uFEFF/, ''))) throw new Error('kein_ics') const gestern = plusTage(heute, -1) const horizont = plusTage(heute, horizontTage) const ereignisse = [] const gesehen = new Set() let ev = null let tiefe = 0 let nr = 0 for (const roh of entfalte(text.replace(/^\uFEFF/, ''))) { const p = zeile(roh) if (!p) continue if (p.name === 'BEGIN') { if (p.wert.toUpperCase() === 'VEVENT') { ev = { uid: '', start: null, ende: null, dauer: null, status: '' } tiefe = 0 } else if (ev) tiefe++ continue } if (p.name === 'END') { if (p.wert.toUpperCase() === 'VEVENT' && ev) { const e = ev ev = null nr++ if (e.status === 'CANCELLED' || !e.start) continue let bis = e.ende if (!bis && e.dauer) bis = plusTage(e.start, e.dauer) if (!bis || bis <= e.start) bis = plusTage(e.start, 1) if (naechte(e.start, bis) > MAX_NAECHTE) bis = plusTage(e.start, MAX_NAECHTE) if (bis < gestern || e.start > horizont) continue let uid = e.uid || `ohne-uid-${nr}` if (gesehen.has(uid)) uid = `${uid}#${nr}` gesehen.add(uid) if (ereignisse.length < MAX_EREIGNISSE) ereignisse.push({ von: e.start, bis, uid }) } else if (ev && tiefe > 0) tiefe-- continue } if (!ev || tiefe > 0) continue if (p.name === 'UID') ev.uid = p.wert.slice(0, 200) else if (p.name === 'STATUS') ev.status = p.wert.toUpperCase() else if (p.name === 'DTSTART') ev.start = tagVon(p.wert) else if (p.name === 'DTEND') ev.ende = tagVon(p.wert) else if (p.name === 'DURATION') ev.dauer = dauerTage(p.wert) } return { ereignisse } } // ---- Schreiben /** Zeile auf hoechstens 75 Oktette falten (Fortsetzungszeilen beginnen mit einem Leerzeichen); schneidet nie mitten in ein Zeichen. */ function falte(z) { const teile = [] let aktuell = '' let bytes = 0 let limit = 75 for (const zeichen of z) { const b = Buffer.byteLength(zeichen) if (bytes + b > limit) { teile.push(aktuell) aktuell = ' ' bytes = 1 limit = 75 } aktuell += zeichen bytes += b } teile.push(aktuell) return teile } const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n') const ohneTrenner = (tag) => tag.replace(/-/g, '') const zeitstempel = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') /** * Kalender mit ganztaegigen Ereignissen "Belegt" bauen (CRLF, gefaltet). Absichtlich keine Gastdaten. * @param {{ name: string, ereignisse: {uid: string, von: string, bis: string}[], jetzt?: Date }} eingabe */ export function baueIcs({ name, ereignisse, jetzt = new Date() }) { const stempel = zeitstempel(jetzt) const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WEBklar//Buchungskalender//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH'] if (name) z.push(`X-WR-CALNAME:${maskiere(name)}`) for (const e of ereignisse) { z.push('BEGIN:VEVENT', `UID:${e.uid}`, `DTSTAMP:${stempel}`, `DTSTART;VALUE=DATE:${ohneTrenner(e.von)}`, `DTEND;VALUE=DATE:${ohneTrenner(e.bis)}`, 'SUMMARY:Belegt', 'TRANSP:OPAQUE', 'END:VEVENT') } z.push('END:VCALENDAR') return z.flatMap(falte).join('\r\n') + '\r\n' }