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
+126
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user