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