Files

162 lines
6.2 KiB
JavaScript

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'
}