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>
261 lines
9.8 KiB
JavaScript
261 lines
9.8 KiB
JavaScript
// 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));
|
||
}
|