Files
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

127 lines
4.2 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.
// 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}`;
}