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
@@ -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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
||||
}
|
||||
export function encodeXml(s) {
|
||||
return String(s ?? '').replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
Reference in new issue
Block a user