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

+227
View File
@@ -0,0 +1,227 @@
// CalDAV/CardDAV-Client für Nextcloud ohne Abhängigkeiten: Discovery, Kalender, Termine, Adressbuch.
export function nextcloudConfigFromEnv(env = process.env) {
const baseUrl = (env.NEXTCLOUD_URL || '').trim().replace(/\/+$/, '');
const user = (env.NEXTCLOUD_USER || '').trim();
const password = (env.NEXTCLOUD_APP_PASSWORD || '').trim();
const calendar = (env.NEXTCLOUD_CALENDAR || 'morning-meeting').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return { baseUrl, user, password, calendar, configured: Boolean(baseUrl && user && password) };
}
// ---------- XML-Hilfen (namespace-präfix-unabhängig) ----------
export function decodeXml(s) {
return String(s ?? '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&#x([0-9a-fA-F]+);/g, (m, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (m, d) => String.fromCodePoint(Number(d)))
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
}
export function encodeXml(s) {
return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
}
// Lokaler Name mit beliebigem Präfix; das Ende ist erreicht, wenn kein Namenszeichen folgt ("write" ≠ "write-properties").
const tag = (local) => `(?:[A-Za-z0-9_-]+:)?${local}(?![A-Za-z0-9_-])`;
export function xmlResponses(xml) {
return [...String(xml).matchAll(new RegExp(`<${tag('response')}[^>]*>([\\s\\S]*?)</${tag('response')}\\s*>`, 'g'))].map((m) => m[1]);
}
export function xmlProp(block, local) {
const m = String(block ?? '').match(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`));
return m ? m[1] : null;
}
export function xmlHasEmpty(block, local) {
return new RegExp(`<${tag(local)}[^>]*/>`).test(String(block ?? ''));
}
export function xmlAll(block, local) {
return [...String(block ?? '').matchAll(new RegExp(`<${tag(local)}[^>]*>([\\s\\S]*?)</${tag(local)}\\s*>`, 'g'))].map((m) => m[1]);
}
const PROPFIND_BODY = (props) => `<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:prop>${props}</d:prop></d:propfind>`;
export function createNextcloudClient({ baseUrl, user, password, calendar = 'morning-meeting', displayName = 'Morning Meeting', fetchImpl = fetch }) {
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
const abs = (href) => new URL(href, baseUrl + '/').toString();
let discovered = null;
async function request(method, url, { headers = {}, body } = {}) {
try {
return await fetchImpl(url, { method, headers: { Authorization: auth, 'User-Agent': 'Rotatory/1.0', ...headers }, body, redirect: 'manual' });
} catch (err) {
throw new Error(`Nextcloud nicht erreichbar: ${err.cause?.message || err.message}`);
}
}
async function dav(method, url, body, depth = '0') {
const res = await request(method, url, { headers: { Depth: depth, 'Content-Type': 'application/xml; charset=utf-8' }, body });
if (res.status === 401) throw new Error('Nextcloud lehnt die Zugangsdaten ab (401). Benutzername oder App-Passwort prüfen.');
return res;
}
async function propfind(url, props, depth = '0') {
const res = await dav('PROPFIND', url, PROPFIND_BODY(props), depth);
return { status: res.status, xml: res.status === 207 ? await res.text() : '' };
}
const client = {
/** Principal, Kalender- und Adressbuch-Home des angemeldeten Kontos ermitteln. */
async discover() {
if (discovered) return discovered;
const root = await propfind(abs('remote.php/dav/'), '<d:current-user-principal/>');
if (root.status !== 207) throw new Error(`Nextcloud antwortet mit HTTP ${root.status} auf ${abs('remote.php/dav/')}`);
const principalHref = decodeXml(xmlProp(xmlProp(root.xml, 'current-user-principal'), 'href'));
if (!principalHref) throw new Error('Nextcloud nennt keinen Principal – ist das eine Nextcloud-URL?');
const p = await propfind(abs(principalHref), '<c:calendar-home-set/><card:addressbook-home-set/><d:displayname/>');
if (p.status !== 207) throw new Error(`Principal nicht lesbar: HTTP ${p.status}`);
const calendarHome = decodeXml(xmlProp(xmlProp(p.xml, 'calendar-home-set'), 'href'));
const addressbookHome = decodeXml(xmlProp(xmlProp(p.xml, 'addressbook-home-set'), 'href'));
if (!calendarHome) throw new Error('Kein Kalender-Home gefunden – ist die Kalender-App aktiviert?');
discovered = {
principalHref,
userId: decodeURIComponent(principalHref.replace(/\/+$/, '').split('/').pop()),
displayName: decodeXml(xmlProp(p.xml, 'displayname') || ''),
calendarHome: abs(calendarHome),
addressbookHome: addressbookHome ? abs(addressbookHome) : null,
};
return discovered;
},
async test() {
const d = await client.discover();
const calendars = await client.listCalendars();
return { ok: true, userId: d.userId, displayName: d.displayName, calendarHome: d.calendarHome, calendars };
},
/** Alle Ereigniskalender (eigene und geteilte) mit Schreibrecht-Information. */
async listCalendars() {
const d = await client.discover();
const r = await propfind(d.calendarHome, '<d:displayname/><d:resourcetype/><d:owner/><d:current-user-privilege-set/><c:supported-calendar-component-set/><oc:owner-displayname/>', '1');
if (r.status !== 207) throw new Error(`Kalenderliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const href = decodeXml(xmlProp(block, 'href') || '');
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'calendar')) continue;
const comps = xmlProp(block, 'supported-calendar-component-set') || '';
const supportsEvents = !comps || /name="VEVENT"/.test(comps);
if (!supportsEvents) continue;
const priv = xmlProp(block, 'current-user-privilege-set') || '';
const writable = xmlHasEmpty(priv, 'write') || xmlHasEmpty(priv, 'write-content') || xmlHasEmpty(priv, 'all');
const ownerHref = decodeXml(xmlProp(xmlProp(block, 'owner'), 'href') || '');
const ownerId = ownerHref ? decodeURIComponent(ownerHref.replace(/\/+$/, '').split('/').pop()) : '';
out.push({
href: abs(href),
id: href.replace(/\/+$/, '').split('/').pop(),
name: decodeXml(xmlProp(block, 'displayname') || ''),
writable,
shared: ownerId !== d.userId,
ownerName: decodeXml(xmlProp(block, 'owner-displayname') || '') || ownerId,
});
}
return out.sort((a, b) => a.name.localeCompare(b.name, 'de'));
},
/** Zielkalender bereitstellen: vorhandener (href) oder eigener Kalender, der bei Bedarf angelegt wird. */
async ensureCalendar(targetHref) {
const d = await client.discover();
if (targetHref) {
const r = await propfind(targetHref, '<d:resourcetype/><d:current-user-privilege-set/>');
if (r.status !== 207) throw new Error(`Zielkalender nicht erreichbar (HTTP ${r.status}). In den Nextcloud-Einstellungen neu wählen.`);
return { href: targetHref, created: false };
}
const href = `${d.calendarHome}${encodeURIComponent(calendar)}/`;
const r = await propfind(href, '<d:resourcetype/>');
if (r.status === 207) return { href, created: false };
if (r.status !== 404) throw new Error(`Kalender-Abfrage fehlgeschlagen: HTTP ${r.status}`);
const body = '<?xml version="1.0" encoding="utf-8"?>'
+ '<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"><d:set><d:prop>'
+ `<d:displayname>${encodeXml(displayName)}</d:displayname>`
+ '<a:calendar-color>#2f6fed</a:calendar-color>'
+ '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
+ '</d:prop></d:set></c:mkcalendar>';
const mk = await dav('MKCALENDAR', href, body);
if (mk.status < 200 || mk.status >= 300) throw new Error(`Kalender konnte nicht angelegt werden: HTTP ${mk.status}`);
return { href, created: true };
},
async putEvent(calendarHref, uid, ics) {
const res = await request('PUT', `${calendarHref}${encodeURIComponent(uid)}.ics`, { headers: { 'Content-Type': 'text/calendar; charset=utf-8' }, body: ics });
if (res.status < 200 || res.status >= 300) {
const text = await res.text().catch(() => '');
throw new Error(`Termin ${uid} nicht gespeichert: HTTP ${res.status} ${text.replace(/<[^>]+>/g, ' ').trim().slice(0, 160)}`);
}
},
async deleteEvent(calendarHref, uidOrHref) {
const url = /^https?:\/\//.test(uidOrHref) ? uidOrHref : `${calendarHref}${encodeURIComponent(uidOrHref)}.ics`;
const res = await request('DELETE', url);
if (res.status === 404) return false;
if (res.status < 200 || res.status >= 300) throw new Error(`Termin nicht gelöscht: HTTP ${res.status}`);
return true;
},
/** Termine eines Kalenders im Zeitraum (ISO-Tage, inklusiv). expand: Wiederholungen serverseitig auflösen. */
async queryEvents(calendarHref, from, to, { expand = false } = {}) {
const start = `${from.replace(/-/g, '')}T000000Z`;
const endDate = new Date(to + 'T00:00:00Z');
endDate.setUTCDate(endDate.getUTCDate() + 1);
// Der Server filtert grob (UTC-Mitternacht); Aufrufer prüfen die Überlappung mit den lokalen Tagen selbst.
const end = `${endDate.toISOString().slice(0, 10).replace(/-/g, '')}T000000Z`;
const data = expand ? `<c:calendar-data><c:expand start="${start}" end="${end}"/></c:calendar-data>` : '<c:calendar-data/>';
const body = `<?xml version="1.0" encoding="utf-8"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/>${data}</d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"><c:time-range start="${start}" end="${end}"/></c:comp-filter></c:comp-filter></c:filter></c:calendar-query>`;
const res = await dav('REPORT', calendarHref, body, '1');
if (res.status !== 207) throw new Error(`Terminabfrage fehlgeschlagen: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => ({
href: abs(decodeXml(xmlProp(block, 'href') || '')),
etag: decodeXml(xmlProp(block, 'getetag') || ''),
ics: decodeXml(xmlProp(block, 'calendar-data') || ''),
})).filter((e) => e.ics);
},
async listAddressBooks() {
const d = await client.discover();
if (!d.addressbookHome) return [];
const r = await propfind(d.addressbookHome, '<d:displayname/><d:resourcetype/>', '1');
if (r.status !== 207) throw new Error(`Adressbuchliste nicht lesbar: HTTP ${r.status}`);
const out = [];
for (const block of xmlResponses(r.xml)) {
const rt = xmlProp(block, 'resourcetype') || '';
if (!xmlHasEmpty(rt, 'addressbook')) continue;
const href = decodeXml(xmlProp(block, 'href') || '');
const id = href.replace(/\/+$/, '').split('/').pop();
out.push({ href: abs(href), id, name: decodeXml(xmlProp(block, 'displayname') || id), system: id === 'z-server-generated--system' });
}
return out.sort((a, b) => Number(b.system) - Number(a.system) || a.name.localeCompare(b.name, 'de'));
},
/** Alle vCards eines Adressbuchs als Text. */
async queryContacts(addressbookHref) {
const body = '<?xml version="1.0" encoding="utf-8"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>';
const res = await dav('REPORT', addressbookHref, body, '1');
if (res.status !== 207) throw new Error(`Adressbuch nicht lesbar: HTTP ${res.status}`);
const xml = await res.text();
return xmlResponses(xml).map((block) => decodeXml(xmlProp(block, 'address-data') || '')).filter(Boolean);
},
/** Profilbild eines Nextcloud-Kontos; null, wenn keines gesetzt ist (Buchstaben-Avatar zählt nicht). */
async fetchAvatar(userId, size = 256) {
const res = await request('GET', abs(`index.php/avatar/${encodeURIComponent(userId)}/${size}`));
if (res.status !== 200) return null;
const custom = res.headers.get('x-nc-iscustomavatar');
if (custom !== null && custom !== '1') return null;
const type = res.headers.get('content-type') || '';
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type.split(';')[0] };
},
/** Bild von einer URL – nur innerhalb der eigenen Nextcloud, damit das App-Passwort nie an fremde Hosts geht. */
async fetchBinary(url) {
let target;
try { target = new URL(url); } catch { return null; }
if (target.origin !== new URL(baseUrl).origin) return null;
const res = await request('GET', url);
if (res.status !== 200) return null;
const type = (res.headers.get('content-type') || '').split(';')[0];
if (!type.startsWith('image/')) return null;
return { buffer: Buffer.from(await res.arrayBuffer()), mime: type };
},
};
return client;
}