// Lesen von iCalendar (VEVENT) und vCard – nur was Rotatory braucht, ohne Abhängigkeiten. const berlinDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }); /** Gefaltete Zeilen (CRLF + Leerzeichen/Tab) wieder zusammenfügen. */ export function unfold(text) { return String(text).replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter((l) => l.length > 0); } /** Eine Content-Line in Name, Parameter und Wert zerlegen. */ export function parseLine(line) { let i = 0; let inQuotes = false; while (i < line.length && (inQuotes || line[i] !== ':')) { if (line[i] === '"') inQuotes = !inQuotes; i += 1; } const head = line.slice(0, i); const value = line.slice(i + 1); const [rawName, ...rawParams] = head.split(';'); const params = {}; for (const p of rawParams) { const eq = p.indexOf('='); if (eq < 0) { params[p.toUpperCase()] = ''; continue; } params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, ''); } return { name: rawName.toUpperCase(), params, value }; } export function unescapeText(s) { // Ein Durchgang, damit '\\\\n' (maskierter Backslash + n) nicht zum Zeilenumbruch wird return String(s ?? '').replace(/\\([\\;,nN])/g, (m, c) => (c === 'n' || c === 'N' ? '\n' : c)); } /** Datum/Zeit-Wert → { date: 'YYYY-MM-DD' (lokal Europe/Berlin), allDay, instant: Date|null }. */ export function toLocalDate(value, params = {}) { const v = String(value).trim(); if (params.VALUE === 'DATE' || /^\d{8}$/.test(v)) { return { date: `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`, allDay: true, instant: null }; } const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/); if (!m) return null; const [, y, mo, d, h, mi, s = '00', z] = m; if (z) { const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)); return { date: berlinDate.format(instant), allDay: false, instant }; } // Wandzeit (TZID oder floating): Datum wie angegeben verwenden return { date: `${y}-${mo}-${d}`, allDay: false, instant: new Date(+y, +mo - 1, +d, +h, +mi, +s) }; } function addDays(iso, n) { const d = new Date(iso + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } function parseDuration(s) { const m = String(s).match(/^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); if (!m) return null; const sign = m[1] ? -1 : 1; const days = (+m[2] || 0) * 7 + (+m[3] || 0); const seconds = (+m[4] || 0) * 3600 + (+m[5] || 0) * 60 + (+m[6] || 0); return { days: sign * days, seconds: sign * seconds }; } /** * Alle VEVENTs eines iCalendar-Textes als { uid, summary, from, to, allDay, rrule, recurrenceId, status, description }. * from/to sind lokale Kalendertage (inklusiv), Zeitzone Europe/Berlin. */ export function parseEvents(text) { const events = []; let cur = null; let depth = []; for (const line of unfold(text)) { const { name, params, value } = parseLine(line); if (name === 'BEGIN') { depth.push(value.toUpperCase()); if (value.toUpperCase() === 'VEVENT') cur = { props: [] }; continue; } if (name === 'END') { const closed = depth.pop(); if (closed === 'VEVENT' && cur) { const ev = finishEvent(cur.props); if (ev) events.push(ev); cur = null; } continue; } if (cur && depth[depth.length - 1] === 'VEVENT') cur.props.push({ name, params, value }); } return events; } function finishEvent(props) { const get = (n) => props.find((p) => p.name === n); const start = get('DTSTART'); if (!start) return null; const s = toLocalDate(start.value, start.params); if (!s) return null; let to = s.date; const end = get('DTEND'); const dur = get('DURATION'); if (end) { const e = toLocalDate(end.value, end.params); if (e) { if (e.allDay) to = addDays(e.date, -1); else if (e.instant) to = s.allDay ? e.date : berlinOrWall(new Date(e.instant.getTime() - 1000), end.params, end.value); else to = e.date; } } else if (dur) { const d = parseDuration(dur.value); if (d) { if (s.allDay) to = addDays(s.date, Math.max(0, d.days - 1)); else { const endInstant = new Date(s.instant.getTime() + (d.days * 86400 + d.seconds) * 1000 - 1000); to = berlinOrWall(endInstant, start.params, start.value); } } } if (to < s.date) to = s.date; return { uid: get('UID')?.value || '', summary: unescapeText(get('SUMMARY')?.value || ''), description: unescapeText(get('DESCRIPTION')?.value || ''), from: s.date, to, allDay: s.allDay, rrule: get('RRULE')?.value || null, recurrenceId: get('RECURRENCE-ID')?.value || null, status: (get('STATUS')?.value || '').toUpperCase(), }; } function berlinOrWall(instant, params, originalValue) { if (/Z$/.test(originalValue)) return berlinDate.format(instant); const pad = (n) => String(n).padStart(2, '0'); return `${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}`; } /** vCards (3.0/4.0) → [{ uid, fn, firstName, lastName, email, cloudId, photo }] */ export function parseVCards(text) { const cards = []; let cur = null; for (const line of unfold(text)) { const { name, params, value } = parseLine(line); if (name === 'BEGIN' && value.toUpperCase() === 'VCARD') { cur = []; continue; } if (name === 'END' && value.toUpperCase() === 'VCARD') { if (cur) cards.push(finishCard(cur)); cur = null; continue; } if (cur) cur.push({ name, params, value }); } return cards; } function splitEscaped(value, sep = ';') { const out = []; let buf = ''; for (let i = 0; i < value.length; i += 1) { if (value[i] === '\\' && i + 1 < value.length) { buf += value[i] + value[i + 1]; i += 1; continue; } if (value[i] === sep) { out.push(buf); buf = ''; continue; } buf += value[i]; } out.push(buf); return out.map(unescapeText); } function finishCard(props) { const get = (n) => props.find((p) => p.name === n); const fn = unescapeText(get('FN')?.value || '').trim(); let firstName = ''; let lastName = ''; const n = get('N'); if (n) { const parts = splitEscaped(n.value); lastName = (parts[0] || '').trim(); firstName = [parts[1], parts[2]].filter(Boolean).join(' ').trim(); } if (!firstName && !lastName && fn) { const idx = fn.lastIndexOf(' '); if (idx > 0) { firstName = fn.slice(0, idx).trim(); lastName = fn.slice(idx + 1).trim(); } else lastName = fn; } else if (!firstName && fn && fn !== lastName) { firstName = fn.replace(lastName, '').trim(); } const emails = props.filter((p) => p.name === 'EMAIL').map((p) => unescapeText(p.value).trim().toLowerCase()).filter(Boolean); const photoProp = get('PHOTO'); let photo = null; if (photoProp) { const v = photoProp.value.trim(); const enc = (photoProp.params.ENCODING || '').toUpperCase(); const dataUrl = v.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/is); if (dataUrl) photo = { kind: 'inline', mime: dataUrl[1] || 'image/jpeg', base64: dataUrl[2].replace(/\s+/g, '') }; else if (enc === 'B' || enc === 'BASE64') photo = { kind: 'inline', mime: `image/${(photoProp.params.TYPE || 'jpeg').toLowerCase().replace(/^image\//, '')}`, base64: v.replace(/\s+/g, '') }; else if (/^https?:\/\//i.test(v)) photo = { kind: 'url', url: v }; } return { uid: unescapeText(get('UID')?.value || '').trim(), fn, firstName, lastName, email: emails[0] || '', cloudId: unescapeText(get('CLOUD')?.value || get('X-CLOUD')?.value || '').trim(), photo, }; } /** * Titel eines Abwesenheitstermins deuten: "Vorname Nachname (Urlaub)" → { name, kind }. * kind: urlaub | krankheit | frei | sonstiges */ export function parseAbsenceSummary(summary) { const s = String(summary || '').trim(); const m = s.match(/^(.*?)\s*[\(\[]([^\)\]]*)[\)\]]\s*$/); const name = (m ? m[1] : s).trim(); const raw = (m ? m[2] : '').trim(); const k = raw.toLowerCase(); let kind = 'sonstiges'; if (/urlaub|vacation|holiday|ferien/.test(k)) kind = 'urlaub'; else if (/krank|sick|kur|reha/.test(k)) kind = 'krankheit'; else if (/frei|off|gleit|elternzeit|home/.test(k)) kind = 'frei'; return { name, kind, raw }; } export function normalizeName(s) { return String(s || '').normalize('NFC').toLowerCase() .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, ' ').trim(); } /** Ordnet einen Namen einer Person zu (Vor+Nach, Nach+Vor, eindeutiger Nachname/Vorname). */ export function matchPerson(name, persons) { const key = normalizeName(name); if (!key) return null; for (const p of persons) { const f = normalizeName(p.firstName); const l = normalizeName(p.lastName); if (key === `${f} ${l}`.trim() || key === `${l} ${f}`.trim()) return p; } const tokens = key.split(' '); if (tokens.length === 1) { const byLast = persons.filter((p) => normalizeName(p.lastName) === key); if (byLast.length === 1) return byLast[0]; const byFirst = persons.filter((p) => normalizeName(p.firstName) === key); if (byFirst.length === 1) return byFirst[0]; } return null; } /** Zusammenhängende oder überlappende Zeiträume je Person und Art verschmelzen. */ export function mergeAbsences(items) { const groups = new Map(); for (const a of items) { const k = `${a.personId}|${a.type}`; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(a); } const out = []; for (const list of groups.values()) { list.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)); let cur = null; for (const a of list) { if (cur && a.from <= addDays(cur.to, 1)) { if (a.to > cur.to) cur.to = a.to; continue; } cur = { ...a }; out.push(cur); } } return out.sort((a, b) => a.from.localeCompare(b.from)); }