// 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}`; }