Files
Rotatory/server/ical.js
T
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

261 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}