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>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
// 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));
}