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

+126
View File
@@ -0,0 +1,126 @@
// iCalendar-Erzeugung (RFC 5545) ohne Abhängigkeiten.
const CRLF = '\r\n';
export function escapeText(s) {
return String(s ?? '')
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
/** Zeilen länger als 75 Oktette werden mit CRLF + Leerzeichen gefaltet. */
export function foldLine(line) {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const parts = [];
let start = 0;
let limit = 75;
while (start < bytes.length) {
let end = Math.min(start + limit, bytes.length);
// Nicht mitten in einer UTF-8-Sequenz trennen
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1;
parts.push((parts.length ? ' ' : '') + bytes.subarray(start, end).toString('utf8'));
start = end;
limit = 74;
}
return parts.join(CRLF);
}
const VTIMEZONE_BERLIN = [
'BEGIN:VTIMEZONE',
'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT',
'TZOFFSETFROM:+0100',
'TZOFFSETTO:+0200',
'TZNAME:CEST',
'DTSTART:19700329T020000',
'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
'END:DAYLIGHT',
'BEGIN:STANDARD',
'TZOFFSETFROM:+0200',
'TZOFFSETTO:+0100',
'TZNAME:CET',
'DTSTART:19701025T030000',
'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
'END:STANDARD',
'END:VTIMEZONE',
];
function stamp(d = new Date()) {
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
}
function addMinutes(hhmm, minutes) {
const [h, m] = hhmm.split(':').map(Number);
const total = h * 60 + m + minutes;
const day = Math.floor(total / (24 * 60));
const rest = total % (24 * 60);
return { day, time: `${String(Math.floor(rest / 60)).padStart(2, '0')}${String(rest % 60).padStart(2, '0')}00` };
}
/**
* Ein VEVENT als Array von (ungefalteten) Zeilen.
* time = 'HH:MM' oder leer für ganztägig.
*/
export function buildEventLines({ uid, date, summary, description = '', time = '', durationMinutes = 15, attendees = [], organizer = null, sequence = 0, now = new Date() }) {
const compact = date.replace(/-/g, '');
const lines = ['BEGIN:VEVENT', `UID:${uid}`, `DTSTAMP:${stamp(now)}`, `SEQUENCE:${sequence}`];
if (time) {
const start = addMinutes(time, 0).time;
const end = addMinutes(time, durationMinutes);
const endDate = end.day ? addDaysCompact(date, end.day) : compact;
lines.push(`DTSTART;TZID=Europe/Berlin:${compact}T${start}`);
lines.push(`DTEND;TZID=Europe/Berlin:${endDate}T${end.time}`);
} else {
lines.push(`DTSTART;VALUE=DATE:${compact}`);
lines.push(`DTEND;VALUE=DATE:${addDaysCompact(date, 1)}`);
}
lines.push(`SUMMARY:${escapeText(summary)}`);
if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
// Teilnehmer nur mit ORGANIZER – ohne ihn verschickt Nextcloud keine Einladungen und iCal-Clients meckern
if (attendees.length > 0 && organizer?.email) {
lines.push(`ORGANIZER;CN=${escapeParam(organizer.name || organizer.email)}:mailto:${organizer.email}`);
for (const a of attendees) {
lines.push(`ATTENDEE;CN=${escapeParam(a.name)};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:${a.email}`);
}
}
lines.push('TRANSP:OPAQUE', 'END:VEVENT');
return lines;
}
function escapeParam(s) {
return '"' + String(s ?? '').replace(/["\r\n]/g, '') + '"';
}
function addDaysCompact(iso, n) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* Vollständiger Kalender aus einer Liste von Event-Zeilenarrays.
* method nur für Dateien setzen (z. B. PUBLISH) – CalDAV-Objekte dürfen laut RFC 4791 keine METHOD tragen,
* Nextcloud lehnt einen PUT damit ab.
*/
export function buildCalendar(eventLineArrays, { name = 'Morning Meeting', method = '' } = {}) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Rotatory//Morning Meeting//DE',
'CALSCALE:GREGORIAN',
...(method ? [`METHOD:${method}`] : []),
`X-WR-CALNAME:${escapeText(name)}`,
'X-WR-TIMEZONE:Europe/Berlin',
...VTIMEZONE_BERLIN,
...eventLineArrays.flat(),
'END:VCALENDAR',
];
return lines.map(foldLine).join(CRLF) + CRLF;
}
export function eventUid(date, host = 'rotatory.local') {
return `rotatory-${date}@${host}`;
}