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:
commit
95756778d8
26 files changed
+4697
No files matched your search
+260
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user