162 lines
6.2 KiB
JavaScript
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'
|
|
}
|