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,18 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
/** Lädt eine .env-Datei in process.env, ohne bereits gesetzte Variablen zu überschreiben. */
|
||||
export function loadEnvFile(file) {
|
||||
if (!fs.existsSync(file)) return false;
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
let value = m[2];
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
||||
else if (value.startsWith('#')) value = ''; // "KEY= # optional" – Kommentar ist kein Wert
|
||||
else value = value.replace(/\s+#.*$/, '').trim();
|
||||
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { addDays } from './scheduler.js';
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const iso = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
|
||||
|
||||
/** Ostersonntag nach der Gauß'schen Osterformel (Meeus/Jones/Butcher). */
|
||||
export function easterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return iso(year, month, day);
|
||||
}
|
||||
|
||||
export const BUNDESLAENDER = {
|
||||
'': 'Nur bundesweite Feiertage',
|
||||
BW: 'Baden-Württemberg', BY: 'Bayern', BE: 'Berlin', BB: 'Brandenburg', HB: 'Bremen',
|
||||
HH: 'Hamburg', HE: 'Hessen', MV: 'Mecklenburg-Vorpommern', NI: 'Niedersachsen',
|
||||
NW: 'Nordrhein-Westfalen', RP: 'Rheinland-Pfalz', SL: 'Saarland', SN: 'Sachsen',
|
||||
ST: 'Sachsen-Anhalt', SH: 'Schleswig-Holstein', TH: 'Thüringen',
|
||||
};
|
||||
|
||||
function bussUndBettag(year) {
|
||||
// Mittwoch vor dem 23. November
|
||||
let d = iso(year, 11, 22);
|
||||
while (new Date(d + 'T00:00:00Z').getUTCDay() !== 3) d = addDays(d, -1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Gesetzliche Feiertage eines Jahres, bundesweit plus die des gewählten Bundeslands. */
|
||||
export function germanHolidays(year, state = '') {
|
||||
const easter = easterSunday(year);
|
||||
const all = [
|
||||
{ date: iso(year, 1, 1), name: 'Neujahr' },
|
||||
{ date: iso(year, 1, 6), name: 'Heilige Drei Könige', states: ['BW', 'BY', 'ST'] },
|
||||
{ date: iso(year, 3, 8), name: 'Internationaler Frauentag', states: ['BE', 'MV'] },
|
||||
{ date: addDays(easter, -2), name: 'Karfreitag' },
|
||||
{ date: addDays(easter, 1), name: 'Ostermontag' },
|
||||
{ date: iso(year, 5, 1), name: 'Tag der Arbeit' },
|
||||
{ date: addDays(easter, 39), name: 'Christi Himmelfahrt' },
|
||||
{ date: addDays(easter, 50), name: 'Pfingstmontag' },
|
||||
{ date: addDays(easter, 60), name: 'Fronleichnam', states: ['BW', 'BY', 'HE', 'NW', 'RP', 'SL'] },
|
||||
{ date: iso(year, 8, 15), name: 'Mariä Himmelfahrt', states: ['SL', 'BY'] },
|
||||
{ date: iso(year, 9, 20), name: 'Weltkindertag', states: ['TH'] },
|
||||
{ date: iso(year, 10, 3), name: 'Tag der Deutschen Einheit' },
|
||||
{ date: iso(year, 10, 31), name: 'Reformationstag', states: ['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'] },
|
||||
{ date: iso(year, 11, 1), name: 'Allerheiligen', states: ['BW', 'BY', 'NW', 'RP', 'SL'] },
|
||||
{ date: bussUndBettag(year), name: 'Buß- und Bettag', states: ['SN'] },
|
||||
{ date: iso(year, 12, 25), name: '1. Weihnachtstag' },
|
||||
{ date: iso(year, 12, 26), name: '2. Weihnachtstag' },
|
||||
];
|
||||
return all
|
||||
.filter((h) => !h.states || h.states.includes(state))
|
||||
.map(({ date, name }) => ({ date, name, auto: true }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
// Lesen von iCalendar (VEVENT) und vCard – nur was Rotatory braucht, ohne Abhängigkeiten.
|
||||
|
||||
const berlinDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
|
||||
/** Gefaltete Zeilen (CRLF + Leerzeichen/Tab) wieder zusammenfügen. */
|
||||
export function unfold(text) {
|
||||
return String(text).replace(/\r?\n[ \t]/g, '').split(/\r?\n/).filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
/** Eine Content-Line in Name, Parameter und Wert zerlegen. */
|
||||
export function parseLine(line) {
|
||||
let i = 0;
|
||||
let inQuotes = false;
|
||||
while (i < line.length && (inQuotes || line[i] !== ':')) {
|
||||
if (line[i] === '"') inQuotes = !inQuotes;
|
||||
i += 1;
|
||||
}
|
||||
const head = line.slice(0, i);
|
||||
const value = line.slice(i + 1);
|
||||
const [rawName, ...rawParams] = head.split(';');
|
||||
const params = {};
|
||||
for (const p of rawParams) {
|
||||
const eq = p.indexOf('=');
|
||||
if (eq < 0) { params[p.toUpperCase()] = ''; continue; }
|
||||
params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, '');
|
||||
}
|
||||
return { name: rawName.toUpperCase(), params, value };
|
||||
}
|
||||
|
||||
export function unescapeText(s) {
|
||||
// Ein Durchgang, damit '\\\\n' (maskierter Backslash + n) nicht zum Zeilenumbruch wird
|
||||
return String(s ?? '').replace(/\\([\\;,nN])/g, (m, c) => (c === 'n' || c === 'N' ? '\n' : c));
|
||||
}
|
||||
|
||||
/** Datum/Zeit-Wert → { date: 'YYYY-MM-DD' (lokal Europe/Berlin), allDay, instant: Date|null }. */
|
||||
export function toLocalDate(value, params = {}) {
|
||||
const v = String(value).trim();
|
||||
if (params.VALUE === 'DATE' || /^\d{8}$/.test(v)) {
|
||||
return { date: `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}`, allDay: true, instant: null };
|
||||
}
|
||||
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/);
|
||||
if (!m) return null;
|
||||
const [, y, mo, d, h, mi, s = '00', z] = m;
|
||||
if (z) {
|
||||
const instant = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
|
||||
return { date: berlinDate.format(instant), allDay: false, instant };
|
||||
}
|
||||
// Wandzeit (TZID oder floating): Datum wie angegeben verwenden
|
||||
return { date: `${y}-${mo}-${d}`, allDay: false, instant: new Date(+y, +mo - 1, +d, +h, +mi, +s) };
|
||||
}
|
||||
|
||||
function addDays(iso, n) {
|
||||
const d = new Date(iso + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function parseDuration(s) {
|
||||
const m = String(s).match(/^(-)?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
||||
if (!m) return null;
|
||||
const sign = m[1] ? -1 : 1;
|
||||
const days = (+m[2] || 0) * 7 + (+m[3] || 0);
|
||||
const seconds = (+m[4] || 0) * 3600 + (+m[5] || 0) * 60 + (+m[6] || 0);
|
||||
return { days: sign * days, seconds: sign * seconds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle VEVENTs eines iCalendar-Textes als { uid, summary, from, to, allDay, rrule, recurrenceId, status, description }.
|
||||
* from/to sind lokale Kalendertage (inklusiv), Zeitzone Europe/Berlin.
|
||||
*/
|
||||
export function parseEvents(text) {
|
||||
const events = [];
|
||||
let cur = null;
|
||||
let depth = [];
|
||||
for (const line of unfold(text)) {
|
||||
const { name, params, value } = parseLine(line);
|
||||
if (name === 'BEGIN') { depth.push(value.toUpperCase()); if (value.toUpperCase() === 'VEVENT') cur = { props: [] }; continue; }
|
||||
if (name === 'END') {
|
||||
const closed = depth.pop();
|
||||
if (closed === 'VEVENT' && cur) { const ev = finishEvent(cur.props); if (ev) events.push(ev); cur = null; }
|
||||
continue;
|
||||
}
|
||||
if (cur && depth[depth.length - 1] === 'VEVENT') cur.props.push({ name, params, value });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function finishEvent(props) {
|
||||
const get = (n) => props.find((p) => p.name === n);
|
||||
const start = get('DTSTART');
|
||||
if (!start) return null;
|
||||
const s = toLocalDate(start.value, start.params);
|
||||
if (!s) return null;
|
||||
let to = s.date;
|
||||
const end = get('DTEND');
|
||||
const dur = get('DURATION');
|
||||
if (end) {
|
||||
const e = toLocalDate(end.value, end.params);
|
||||
if (e) {
|
||||
if (e.allDay) to = addDays(e.date, -1);
|
||||
else if (e.instant) to = s.allDay ? e.date : berlinOrWall(new Date(e.instant.getTime() - 1000), end.params, end.value);
|
||||
else to = e.date;
|
||||
}
|
||||
} else if (dur) {
|
||||
const d = parseDuration(dur.value);
|
||||
if (d) {
|
||||
if (s.allDay) to = addDays(s.date, Math.max(0, d.days - 1));
|
||||
else {
|
||||
const endInstant = new Date(s.instant.getTime() + (d.days * 86400 + d.seconds) * 1000 - 1000);
|
||||
to = berlinOrWall(endInstant, start.params, start.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to < s.date) to = s.date;
|
||||
return {
|
||||
uid: get('UID')?.value || '',
|
||||
summary: unescapeText(get('SUMMARY')?.value || ''),
|
||||
description: unescapeText(get('DESCRIPTION')?.value || ''),
|
||||
from: s.date,
|
||||
to,
|
||||
allDay: s.allDay,
|
||||
rrule: get('RRULE')?.value || null,
|
||||
recurrenceId: get('RECURRENCE-ID')?.value || null,
|
||||
status: (get('STATUS')?.value || '').toUpperCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function berlinOrWall(instant, params, originalValue) {
|
||||
if (/Z$/.test(originalValue)) return berlinDate.format(instant);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${instant.getFullYear()}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}`;
|
||||
}
|
||||
|
||||
/** vCards (3.0/4.0) → [{ uid, fn, firstName, lastName, email, cloudId, photo }] */
|
||||
export function parseVCards(text) {
|
||||
const cards = [];
|
||||
let cur = null;
|
||||
for (const line of unfold(text)) {
|
||||
const { name, params, value } = parseLine(line);
|
||||
if (name === 'BEGIN' && value.toUpperCase() === 'VCARD') { cur = []; continue; }
|
||||
if (name === 'END' && value.toUpperCase() === 'VCARD') { if (cur) cards.push(finishCard(cur)); cur = null; continue; }
|
||||
if (cur) cur.push({ name, params, value });
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
function splitEscaped(value, sep = ';') {
|
||||
const out = [];
|
||||
let buf = '';
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
if (value[i] === '\\' && i + 1 < value.length) { buf += value[i] + value[i + 1]; i += 1; continue; }
|
||||
if (value[i] === sep) { out.push(buf); buf = ''; continue; }
|
||||
buf += value[i];
|
||||
}
|
||||
out.push(buf);
|
||||
return out.map(unescapeText);
|
||||
}
|
||||
|
||||
function finishCard(props) {
|
||||
const get = (n) => props.find((p) => p.name === n);
|
||||
const fn = unescapeText(get('FN')?.value || '').trim();
|
||||
let firstName = '';
|
||||
let lastName = '';
|
||||
const n = get('N');
|
||||
if (n) {
|
||||
const parts = splitEscaped(n.value);
|
||||
lastName = (parts[0] || '').trim();
|
||||
firstName = [parts[1], parts[2]].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
if (!firstName && !lastName && fn) {
|
||||
const idx = fn.lastIndexOf(' ');
|
||||
if (idx > 0) { firstName = fn.slice(0, idx).trim(); lastName = fn.slice(idx + 1).trim(); } else lastName = fn;
|
||||
} else if (!firstName && fn && fn !== lastName) {
|
||||
firstName = fn.replace(lastName, '').trim();
|
||||
}
|
||||
const emails = props.filter((p) => p.name === 'EMAIL').map((p) => unescapeText(p.value).trim().toLowerCase()).filter(Boolean);
|
||||
const photoProp = get('PHOTO');
|
||||
let photo = null;
|
||||
if (photoProp) {
|
||||
const v = photoProp.value.trim();
|
||||
const enc = (photoProp.params.ENCODING || '').toUpperCase();
|
||||
const dataUrl = v.match(/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/is);
|
||||
if (dataUrl) photo = { kind: 'inline', mime: dataUrl[1] || 'image/jpeg', base64: dataUrl[2].replace(/\s+/g, '') };
|
||||
else if (enc === 'B' || enc === 'BASE64') photo = { kind: 'inline', mime: `image/${(photoProp.params.TYPE || 'jpeg').toLowerCase().replace(/^image\//, '')}`, base64: v.replace(/\s+/g, '') };
|
||||
else if (/^https?:\/\//i.test(v)) photo = { kind: 'url', url: v };
|
||||
}
|
||||
return {
|
||||
uid: unescapeText(get('UID')?.value || '').trim(),
|
||||
fn,
|
||||
firstName,
|
||||
lastName,
|
||||
email: emails[0] || '',
|
||||
cloudId: unescapeText(get('CLOUD')?.value || get('X-CLOUD')?.value || '').trim(),
|
||||
photo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Titel eines Abwesenheitstermins deuten: "Vorname Nachname (Urlaub)" → { name, kind }.
|
||||
* kind: urlaub | krankheit | frei | sonstiges
|
||||
*/
|
||||
export function parseAbsenceSummary(summary) {
|
||||
const s = String(summary || '').trim();
|
||||
const m = s.match(/^(.*?)\s*[\(\[]([^\)\]]*)[\)\]]\s*$/);
|
||||
const name = (m ? m[1] : s).trim();
|
||||
const raw = (m ? m[2] : '').trim();
|
||||
const k = raw.toLowerCase();
|
||||
let kind = 'sonstiges';
|
||||
if (/urlaub|vacation|holiday|ferien/.test(k)) kind = 'urlaub';
|
||||
else if (/krank|sick|kur|reha/.test(k)) kind = 'krankheit';
|
||||
else if (/frei|off|gleit|elternzeit|home/.test(k)) kind = 'frei';
|
||||
return { name, kind, raw };
|
||||
}
|
||||
|
||||
export function normalizeName(s) {
|
||||
return String(s || '').normalize('NFC').toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/** Ordnet einen Namen einer Person zu (Vor+Nach, Nach+Vor, eindeutiger Nachname/Vorname). */
|
||||
export function matchPerson(name, persons) {
|
||||
const key = normalizeName(name);
|
||||
if (!key) return null;
|
||||
for (const p of persons) {
|
||||
const f = normalizeName(p.firstName);
|
||||
const l = normalizeName(p.lastName);
|
||||
if (key === `${f} ${l}`.trim() || key === `${l} ${f}`.trim()) return p;
|
||||
}
|
||||
const tokens = key.split(' ');
|
||||
if (tokens.length === 1) {
|
||||
const byLast = persons.filter((p) => normalizeName(p.lastName) === key);
|
||||
if (byLast.length === 1) return byLast[0];
|
||||
const byFirst = persons.filter((p) => normalizeName(p.firstName) === key);
|
||||
if (byFirst.length === 1) return byFirst[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zusammenhängende oder überlappende Zeiträume je Person und Art verschmelzen. */
|
||||
export function mergeAbsences(items) {
|
||||
const groups = new Map();
|
||||
for (const a of items) {
|
||||
const k = `${a.personId}|${a.type}`;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(a);
|
||||
}
|
||||
const out = [];
|
||||
for (const list of groups.values()) {
|
||||
list.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
||||
let cur = null;
|
||||
for (const a of list) {
|
||||
if (cur && a.from <= addDays(cur.to, 1)) { if (a.to > cur.to) cur.to = a.to; continue; }
|
||||
cur = { ...a };
|
||||
out.push(cur);
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.from.localeCompare(b.from));
|
||||
}
|
||||
+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}`;
|
||||
}
|
||||
+612
@@ -0,0 +1,612 @@
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadEnvFile } from './env.js';
|
||||
import { createStore, imageMime } from './store.js';
|
||||
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||||
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||||
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||||
import { nextcloudConfigFromEnv, createNextcloudClient } from './nextcloud.js';
|
||||
import { parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences } from './ical.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadEnvFile(path.join(__dirname, '..', '.env'));
|
||||
|
||||
const dataDir = process.env.VCH_DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
const store = createStore(dataDir);
|
||||
const nc = nextcloudConfigFromEnv();
|
||||
if (String(process.env.NEXTCLOUD_INSECURE_TLS).toLowerCase() === 'true') {
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
console.warn('Warnung: TLS-Zertifikate werden nicht geprüft (NEXTCLOUD_INSECURE_TLS=true).');
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('etag', false);
|
||||
|
||||
// Healthcheck ohne Passwort, damit die Plattform den Prüfling erreichen kann
|
||||
app.get('/health', (req, res) => res.type('text/plain').send('ok'));
|
||||
|
||||
// Optionaler Zugriffsschutz
|
||||
const appPassword = (process.env.APP_PASSWORD || '').trim();
|
||||
if (appPassword) {
|
||||
app.use((req, res, next) => {
|
||||
const header = req.headers.authorization || '';
|
||||
const given = header.startsWith('Basic ') ? Buffer.from(header.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':') : '';
|
||||
const a = Buffer.from(given);
|
||||
const b = Buffer.from(appPassword);
|
||||
if (a.length === b.length && timingSafeEqual(a, b)) return next();
|
||||
res.set('WWW-Authenticate', 'Basic realm="Rotatory", charset="UTF-8"');
|
||||
res.status(401).send('Zugang nur mit Passwort.');
|
||||
});
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
const S = () => store.state;
|
||||
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
|
||||
const bad = (res, msg) => res.status(400).json({ error: msg });
|
||||
const blockedDays = () => new Set(S().holidays.map((h) => h.date));
|
||||
const ABSENCE_TYPES = ['urlaub', 'krankheit', 'frei', 'sonstiges'];
|
||||
|
||||
// Beim ersten Start: Feiertage für dieses und nächstes Jahr vorbelegen
|
||||
if (S().holidays.length === 0 && !S().settings.holidaysSeeded) {
|
||||
const year = new Date().getFullYear();
|
||||
for (const h of [...germanHolidays(year, S().settings.bundesland), ...germanHolidays(year + 1, S().settings.bundesland)]) S().holidays.push(h);
|
||||
S().settings.holidaysSeeded = true;
|
||||
store.save();
|
||||
}
|
||||
|
||||
/** Gehört eine Kalender-/Adressbuch-URL zur konfigurierten Nextcloud (gleicher Origin, gleicher Pfadstamm)? */
|
||||
function isOurNextcloudUrl(href) {
|
||||
if (!nc.baseUrl || !href) return false;
|
||||
try {
|
||||
const a = new URL(href);
|
||||
const b = new URL(nc.baseUrl + '/');
|
||||
return a.origin === b.origin && a.pathname.startsWith(b.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function personsForScheduling() {
|
||||
return S().persons.map((p) => ({ id: p.id, canMain: Boolean(p.canMain), canCo: Boolean(p.canCo), active: p.active !== false, freeDays: p.freeDays || [] }));
|
||||
}
|
||||
function personLabel(p) {
|
||||
return p ? `${p.firstName} ${p.lastName}`.trim() : 'unbesetzt';
|
||||
}
|
||||
function publicPerson(p) {
|
||||
return { ...p, hasPhoto: store.hasPhoto(p.id) };
|
||||
}
|
||||
|
||||
// ---------- Gesamtzustand ----------
|
||||
app.get('/api/state', (req, res) => {
|
||||
const st = S();
|
||||
res.json({
|
||||
persons: st.persons.map(publicPerson),
|
||||
absences: st.absences,
|
||||
holidays: st.holidays,
|
||||
assignments: st.assignments,
|
||||
exported: st.exported,
|
||||
settings: st.settings,
|
||||
bundeslaender: BUNDESLAENDER,
|
||||
absenceTypes: ABSENCE_TYPES,
|
||||
nextcloud: { configured: nc.configured, calendar: nc.calendar, user: nc.user, baseUrl: nc.baseUrl },
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Personen ----------
|
||||
function readPerson(body, existing = {}) {
|
||||
const firstName = clean(body.firstName);
|
||||
const lastName = clean(body.lastName);
|
||||
if (!firstName || !lastName) return { error: 'Vor- und Nachname sind Pflicht.' };
|
||||
const email = clean(body.email).toLowerCase();
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'E-Mail-Adresse ist ungültig.' };
|
||||
let freeDays = existing.freeDays || [];
|
||||
if (body.freeDays !== undefined) {
|
||||
if (!Array.isArray(body.freeDays)) return { error: 'freeDays muss eine Liste sein.' };
|
||||
freeDays = [...new Set(body.freeDays.map(Number).filter((d) => Number.isInteger(d) && d >= 1 && d <= 5))].sort();
|
||||
}
|
||||
return {
|
||||
person: {
|
||||
...existing,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
canMain: body.canMain !== undefined ? Boolean(body.canMain) : existing.canMain ?? true,
|
||||
canCo: body.canCo !== undefined ? Boolean(body.canCo) : existing.canCo ?? true,
|
||||
active: body.active !== undefined ? Boolean(body.active) : existing.active ?? true,
|
||||
freeDays,
|
||||
note: body.note !== undefined ? clean(body.note, 300) : existing.note || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/persons', (req, res) => {
|
||||
const r = readPerson(req.body || {});
|
||||
if (r.error) return bad(res, r.error);
|
||||
const person = { id: randomUUID(), ...r.person, photoVersion: 0 };
|
||||
S().persons.push(person);
|
||||
store.save();
|
||||
res.status(201).json(publicPerson(person));
|
||||
});
|
||||
|
||||
app.put('/api/persons/:id', (req, res) => {
|
||||
const p = S().persons.find((x) => x.id === req.params.id);
|
||||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||
const r = readPerson(req.body || {}, p);
|
||||
if (r.error) return bad(res, r.error);
|
||||
Object.assign(p, r.person);
|
||||
store.save();
|
||||
res.json(publicPerson(p));
|
||||
});
|
||||
|
||||
app.delete('/api/persons/:id', (req, res) => {
|
||||
const st = S();
|
||||
const idx = st.persons.findIndex((x) => x.id === req.params.id);
|
||||
if (idx < 0) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||
const id = st.persons[idx].id;
|
||||
st.persons.splice(idx, 1);
|
||||
st.absences = st.absences.filter((a) => a.personId !== id);
|
||||
for (const a of Object.values(st.assignments)) {
|
||||
if (a.main === id) a.main = null;
|
||||
if (a.co === id) a.co = null;
|
||||
}
|
||||
store.deletePhoto(id);
|
||||
store.save();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.put('/api/persons/:id/photo', express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '5mb' }), (req, res) => {
|
||||
const p = S().persons.find((x) => x.id === req.params.id);
|
||||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||
if (!Buffer.isBuffer(req.body) || req.body.length === 0 || !imageMime(req.body)) return bad(res, 'Kein gültiges Bild übermittelt (erlaubt: JPEG, PNG, WebP).');
|
||||
store.savePhoto(p.id, req.body);
|
||||
p.photoVersion = (p.photoVersion || 0) + 1;
|
||||
store.save();
|
||||
res.json(publicPerson(p));
|
||||
});
|
||||
|
||||
app.delete('/api/persons/:id/photo', (req, res) => {
|
||||
const p = S().persons.find((x) => x.id === req.params.id);
|
||||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||||
store.deletePhoto(p.id);
|
||||
p.photoVersion = (p.photoVersion || 0) + 1;
|
||||
store.save();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.get('/photos/:id', (req, res) => {
|
||||
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
|
||||
const buf = store.readPhoto(id);
|
||||
if (!buf) return res.status(404).end();
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.type(imageMime(buf) || 'application/octet-stream');
|
||||
res.send(buf);
|
||||
});
|
||||
|
||||
// ---------- Abwesenheiten ----------
|
||||
app.post('/api/absences', (req, res) => {
|
||||
const { personId, from, to, type = 'urlaub', note } = req.body || {};
|
||||
if (!S().persons.some((p) => p.id === personId)) return bad(res, 'Person nicht gefunden.');
|
||||
if (!isValidDate(from) || !isValidDate(to)) return bad(res, 'Ungültiges Datum.');
|
||||
if (to < from) return bad(res, 'Das Ende liegt vor dem Beginn.');
|
||||
if (!ABSENCE_TYPES.includes(type)) return bad(res, 'Unbekannte Abwesenheitsart.');
|
||||
const absence = { id: randomUUID(), personId, from, to, type, note: clean(note, 200), source: 'manuell' };
|
||||
S().absences.push(absence);
|
||||
store.save();
|
||||
res.status(201).json(absence);
|
||||
});
|
||||
|
||||
app.delete('/api/absences/:id', (req, res) => {
|
||||
const st = S();
|
||||
const before = st.absences.length;
|
||||
st.absences = st.absences.filter((a) => a.id !== req.params.id);
|
||||
if (st.absences.length === before) return res.status(404).json({ error: 'Abwesenheit nicht gefunden.' });
|
||||
store.save();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- Feiertage / betriebsfreie Tage ----------
|
||||
function upsertHoliday(h) {
|
||||
const st = S();
|
||||
st.holidays = st.holidays.filter((x) => x.date !== h.date);
|
||||
st.holidays.push(h);
|
||||
st.holidays.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
app.post('/api/holidays', (req, res) => {
|
||||
const { date, name } = req.body || {};
|
||||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||
upsertHoliday({ date, name: clean(name) || 'Kein Meeting', auto: false });
|
||||
store.save();
|
||||
res.status(201).json(S().holidays);
|
||||
});
|
||||
|
||||
app.post('/api/holidays/generate', (req, res) => {
|
||||
const year = Number(req.body?.year);
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 2100) return bad(res, 'Ungültiges Jahr.');
|
||||
for (const h of germanHolidays(year, S().settings.bundesland)) upsertHoliday(h);
|
||||
store.save();
|
||||
res.json(S().holidays);
|
||||
});
|
||||
|
||||
app.delete('/api/holidays/:date', (req, res) => {
|
||||
const st = S();
|
||||
st.holidays = st.holidays.filter((h) => h.date !== req.params.date);
|
||||
store.save();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---------- Einstellungen ----------
|
||||
app.put('/api/settings', (req, res) => {
|
||||
const s = S().settings;
|
||||
const b = req.body || {};
|
||||
if (b.meetingTime !== undefined) {
|
||||
if (b.meetingTime !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(b.meetingTime)) return bad(res, 'Uhrzeit im Format HH:MM angeben (oder leer für ganztägig).');
|
||||
s.meetingTime = b.meetingTime;
|
||||
}
|
||||
if (b.durationMinutes !== undefined) {
|
||||
const d = Number(b.durationMinutes);
|
||||
if (!Number.isInteger(d) || d < 5 || d > 480) return bad(res, 'Dauer zwischen 5 und 480 Minuten.');
|
||||
s.durationMinutes = d;
|
||||
}
|
||||
if (b.bundesland !== undefined) {
|
||||
if (!Object.hasOwn(BUNDESLAENDER, b.bundesland)) return bad(res, 'Unbekanntes Bundesland.');
|
||||
s.bundesland = b.bundesland;
|
||||
}
|
||||
if (b.calendarDisplayName !== undefined) s.calendarDisplayName = clean(b.calendarDisplayName) || 'Morning Meeting';
|
||||
if (b.inviteAttendees !== undefined) s.inviteAttendees = Boolean(b.inviteAttendees);
|
||||
if (b.historyWindowDays !== undefined) {
|
||||
const d = Number(b.historyWindowDays);
|
||||
if (!Number.isInteger(d) || d < 0 || d > 365) return bad(res, 'Fairness-Fenster zwischen 0 und 365 Tagen.');
|
||||
s.historyWindowDays = d;
|
||||
}
|
||||
for (const key of ['targetCalendarHref', 'absenceCalendarHref']) {
|
||||
if (b[key] === undefined) continue;
|
||||
const href = clean(b[key], 500);
|
||||
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||
s[key] = href;
|
||||
const nameKey = key.replace('Href', 'Name');
|
||||
s[nameKey] = href ? clean(b[nameKey], 120) : '';
|
||||
}
|
||||
store.save();
|
||||
res.json(s);
|
||||
});
|
||||
|
||||
// ---------- Planung ----------
|
||||
function parseRange(body) {
|
||||
const { from, to } = body || {};
|
||||
if (!isValidDate(from) || !isValidDate(to)) return { error: 'Ungültiger Zeitraum.' };
|
||||
if (to < from) return { error: 'Das Ende liegt vor dem Beginn.' };
|
||||
if (addDays(from, 400) < to) return { error: 'Höchstens 400 Tage auf einmal.' };
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
app.post('/api/schedule/generate', (req, res) => {
|
||||
const r = parseRange(req.body);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const overwrite = Boolean(req.body.overwrite);
|
||||
const st = S();
|
||||
const dates = workingDays(r.from, r.to, blockedDays());
|
||||
const existing = {};
|
||||
for (const d of dates) {
|
||||
const ex = st.assignments[d];
|
||||
if (!ex) continue;
|
||||
// Ohne „überschreiben": volle Tage behalten, halb besetzte nur auffüllen
|
||||
existing[d] = overwrite ? { ...ex } : { ...ex, keep: Boolean(ex.main && ex.co), fill: Boolean(ex.main || ex.co) };
|
||||
}
|
||||
const generated = generateSchedule({
|
||||
dates,
|
||||
persons: personsForScheduling(),
|
||||
absences: st.absences,
|
||||
existing,
|
||||
history: st.assignments,
|
||||
blocked: blockedDays(),
|
||||
historyWindowDays: st.settings.historyWindowDays,
|
||||
});
|
||||
Object.assign(st.assignments, generated);
|
||||
for (const d of Object.keys(st.assignments)) {
|
||||
if (d >= r.from && d <= r.to && !dates.includes(d) && !st.assignments[d].off) delete st.assignments[d];
|
||||
}
|
||||
store.save();
|
||||
const unfilled = dates.filter((d) => !st.assignments[d].off && (!st.assignments[d].main || !st.assignments[d].co));
|
||||
res.json({ assignments: st.assignments, planned: dates.length, unfilled });
|
||||
});
|
||||
|
||||
app.post('/api/schedule/clear', (req, res) => {
|
||||
const r = parseRange(req.body);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const st = S();
|
||||
for (const d of Object.keys(st.assignments)) {
|
||||
if (d >= r.from && d <= r.to && !st.assignments[d].locked) delete st.assignments[d];
|
||||
}
|
||||
store.save();
|
||||
res.json({ assignments: st.assignments });
|
||||
});
|
||||
|
||||
app.put('/api/assignments/:date', (req, res) => {
|
||||
const date = req.params.date;
|
||||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||
const st = S();
|
||||
const b = req.body || {};
|
||||
const valid = (id) => id === null || id === undefined || st.persons.some((p) => p.id === id);
|
||||
if (!valid(b.main) || !valid(b.co)) return bad(res, 'Person nicht gefunden.');
|
||||
if (b.main && b.main === b.co) return bad(res, 'Haupt- und Nebenmoderator müssen verschieden sein.');
|
||||
const current = st.assignments[date] || {};
|
||||
st.assignments[date] = {
|
||||
main: b.main !== undefined ? b.main : current.main ?? null,
|
||||
co: b.co !== undefined ? b.co : current.co ?? null,
|
||||
locked: b.locked !== undefined ? Boolean(b.locked) : Boolean(current.locked),
|
||||
off: b.off !== undefined ? Boolean(b.off) : Boolean(current.off),
|
||||
};
|
||||
store.save();
|
||||
res.json(st.assignments[date]);
|
||||
});
|
||||
|
||||
app.post('/api/assignments/:date/reroll', (req, res) => {
|
||||
const date = req.params.date;
|
||||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||||
const st = S();
|
||||
const history = { ...st.assignments };
|
||||
delete history[date];
|
||||
const generated = generateSchedule({
|
||||
dates: [date],
|
||||
persons: personsForScheduling(),
|
||||
absences: st.absences,
|
||||
history,
|
||||
blocked: blockedDays(),
|
||||
historyWindowDays: st.settings.historyWindowDays,
|
||||
});
|
||||
// preview: nur Vorschlag liefern, Speichern übernimmt der Dialog
|
||||
if (!req.body?.preview) {
|
||||
st.assignments[date] = generated[date];
|
||||
store.save();
|
||||
}
|
||||
res.json(generated[date]);
|
||||
});
|
||||
|
||||
app.get('/api/stats', (req, res) => {
|
||||
const r = parseRange(req.query);
|
||||
if (r.error) return bad(res, r.error);
|
||||
res.json(countAssignments(S().assignments, r.from, r.to));
|
||||
});
|
||||
|
||||
// ---------- Export ----------
|
||||
function eventsForRange(from, to) {
|
||||
const st = S();
|
||||
const byId = Object.fromEntries(st.persons.map((p) => [p.id, p]));
|
||||
const blocked = blockedDays();
|
||||
const organizer = st.settings.inviteAttendees && /@/.test(nc.user) ? { name: 'Morning Meeting (Rotatory)', email: nc.user } : null;
|
||||
const events = [];
|
||||
for (const [date, a] of Object.entries(st.assignments).sort()) {
|
||||
if (date < from || date > to || a.off || blocked.has(date) || (!a.main && !a.co)) continue;
|
||||
const main = byId[a.main];
|
||||
const co = byId[a.co];
|
||||
const attendees = organizer
|
||||
? [main, co].filter((p) => p && p.email).map((p) => ({ name: personLabel(p), email: p.email }))
|
||||
: [];
|
||||
events.push({
|
||||
date,
|
||||
uid: eventUid(date, nc.baseUrl ? new URL(nc.baseUrl).hostname : 'rotatory.local'),
|
||||
summary: `Morning Meeting: ${personLabel(main)} / ${personLabel(co)}`,
|
||||
description: `Hauptmoderation: ${personLabel(main)}\nNebenmoderation: ${personLabel(co)}\n\nGeplant mit Rotatory.`,
|
||||
time: st.settings.meetingTime,
|
||||
durationMinutes: st.settings.durationMinutes,
|
||||
attendees,
|
||||
organizer,
|
||||
sequence: (st.exported[date]?.sequence || 0),
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
app.get('/api/export.ics', (req, res) => {
|
||||
const r = parseRange(req.query);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const ics = buildCalendar(eventsForRange(r.from, r.to).map(buildEventLines), { name: S().settings.calendarDisplayName, method: 'PUBLISH' });
|
||||
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.set('Content-Disposition', `attachment; filename="morning-meeting-${r.from}-${r.to}.ics"`);
|
||||
res.send(ics);
|
||||
});
|
||||
|
||||
// ---------- Nextcloud ----------
|
||||
function nextcloudClient() {
|
||||
if (!nc.configured) throw new Error('Nextcloud ist nicht konfiguriert: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD setzen.');
|
||||
return createNextcloudClient({ ...nc, displayName: S().settings.calendarDisplayName });
|
||||
}
|
||||
const ncError = (res, err) => res.status(502).json({ error: err.message });
|
||||
|
||||
app.post('/api/nextcloud/test', async (req, res) => {
|
||||
try {
|
||||
const r = await nextcloudClient().test();
|
||||
res.json({ ...r, target: S().settings.targetCalendarHref, absenceSource: S().settings.absenceCalendarHref });
|
||||
} catch (err) { ncError(res, err); }
|
||||
});
|
||||
|
||||
app.get('/api/nextcloud/calendars', async (req, res) => {
|
||||
try {
|
||||
res.json({ calendars: await nextcloudClient().listCalendars(), defaultId: nc.calendar });
|
||||
} catch (err) { ncError(res, err); }
|
||||
});
|
||||
|
||||
app.post('/api/nextcloud/sync', async (req, res) => {
|
||||
const r = parseRange(req.body);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const removeForeign = Boolean(req.body.removeForeign);
|
||||
const st = S();
|
||||
if (req.body.targetCalendarHref !== undefined) {
|
||||
const href = clean(req.body.targetCalendarHref, 500);
|
||||
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||
st.settings.targetCalendarHref = href;
|
||||
st.settings.targetCalendarName = href ? clean(req.body.targetCalendarName, 120) : '';
|
||||
}
|
||||
try {
|
||||
const client = nextcloudClient();
|
||||
const { href, created } = await client.ensureCalendar(st.settings.targetCalendarHref || null);
|
||||
// Zielwechsel: Termine im Zeitraum, die noch in einem anderen Kalender liegen, dort entfernen (sie werden gleich neu geschrieben)
|
||||
const moved = [];
|
||||
for (const [date, info] of Object.entries(st.exported)) {
|
||||
if (date >= r.from && date <= r.to && info.calendarHref && info.calendarHref !== href) {
|
||||
try { await client.deleteEvent(info.calendarHref, info.uid); moved.push(date); delete st.exported[date]; } catch { /* alter Kalender ggf. weg */ }
|
||||
}
|
||||
}
|
||||
const events = eventsForRange(r.from, r.to);
|
||||
const written = [];
|
||||
const errors = [];
|
||||
for (const ev of events) {
|
||||
const sequence = (st.exported[ev.date]?.sequence || 0) + 1;
|
||||
try {
|
||||
await client.putEvent(href, ev.uid, buildCalendar([buildEventLines({ ...ev, sequence })]));
|
||||
st.exported[ev.date] = { uid: ev.uid, sequence, at: new Date().toISOString(), calendarHref: href };
|
||||
written.push(ev.date);
|
||||
} catch (err) {
|
||||
errors.push(`${ev.date}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const removed = [];
|
||||
const stillThere = new Set(events.map((e) => e.date));
|
||||
for (const [date, info] of Object.entries(st.exported)) {
|
||||
if (date < r.from || date > r.to || stillThere.has(date)) continue;
|
||||
try {
|
||||
await client.deleteEvent(href, info.uid);
|
||||
delete st.exported[date];
|
||||
removed.push(date);
|
||||
} catch (err) {
|
||||
errors.push(`${date}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Fremde Termine im Zeitraum (z. B. aus einer früheren Planung mit einem anderen Werkzeug)
|
||||
let foreign = 0;
|
||||
let foreignRemoved = 0;
|
||||
let foreignRecurring = 0;
|
||||
try {
|
||||
const existing = await client.queryEvents(href, r.from, r.to);
|
||||
for (const item of existing) {
|
||||
const evs = parseEvents(item.ics);
|
||||
if (evs.length === 0 || evs.some((e) => e.uid.startsWith('rotatory-'))) continue;
|
||||
if (!evs.some((e) => e.to >= r.from && e.from <= r.to)) continue; // Server filtert grob, wir genau
|
||||
if (evs.some((e) => e.rrule)) { foreignRecurring += 1; continue; } // ganze Serien nie löschen
|
||||
foreign += 1;
|
||||
if (removeForeign) {
|
||||
try { await client.deleteEvent(href, item.href); foreignRemoved += 1; } catch (err) { errors.push(`Fremdtermin: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Fremde Termine konnten nicht geprüft werden: ${err.message}`);
|
||||
}
|
||||
store.save();
|
||||
res.json({ created, written: written.length, removed: removed.length, moved: moved.length, foreign, foreignRemoved, foreignRecurring, errors, calendarUrl: href });
|
||||
} catch (err) {
|
||||
store.save();
|
||||
ncError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Abwesenheiten aus einem Nextcloud-Kalender lesen ("Vorname Nachname (Urlaub)"). */
|
||||
app.post('/api/nextcloud/absences/import', async (req, res) => {
|
||||
const r = parseRange(req.body);
|
||||
if (r.error) return bad(res, r.error);
|
||||
const st = S();
|
||||
const href = clean(req.body.calendarHref, 500) || st.settings.absenceCalendarHref;
|
||||
if (!href) return bad(res, 'Kein Quellkalender gewählt.');
|
||||
if (!isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||||
try {
|
||||
const client = nextcloudClient();
|
||||
const items = await client.queryEvents(href, r.from, r.to, { expand: true });
|
||||
const found = [];
|
||||
const unmatched = new Map();
|
||||
let cancelled = 0;
|
||||
for (const item of items) {
|
||||
for (const ev of parseEvents(item.ics)) {
|
||||
if (ev.status === 'CANCELLED') { cancelled += 1; continue; }
|
||||
if (ev.to < r.from || ev.from > r.to) continue;
|
||||
const { name, kind } = parseAbsenceSummary(ev.summary);
|
||||
const person = matchPerson(name, st.persons);
|
||||
if (!person) { unmatched.set(name, (unmatched.get(name) || 0) + 1); continue; }
|
||||
found.push({ personId: person.id, from: ev.from, to: ev.to, type: kind });
|
||||
}
|
||||
}
|
||||
const merged = mergeAbsences(found).map((a) => ({ id: randomUUID(), ...a, note: '', source: 'nextcloud' }));
|
||||
// Früher importierte Abwesenheiten, die den Zeitraum berühren, ersetzen
|
||||
const before = st.absences.length;
|
||||
st.absences = st.absences.filter((a) => !(a.source === 'nextcloud' && a.from <= r.to && a.to >= r.from));
|
||||
const replaced = before - st.absences.length;
|
||||
st.absences.push(...merged);
|
||||
if (req.body.calendarHref) { st.settings.absenceCalendarHref = href; st.settings.absenceCalendarName = clean(req.body.calendarName, 120); }
|
||||
store.save();
|
||||
res.json({ imported: merged.length, replaced, cancelled, events: items.length, unmatched: [...unmatched.entries()].map(([name, count]) => ({ name, count })), absences: st.absences });
|
||||
} catch (err) { ncError(res, err); }
|
||||
});
|
||||
|
||||
/** Kontakte (Nextcloud-Konten) zur Auswahl anbieten. */
|
||||
app.get('/api/nextcloud/contacts', async (req, res) => {
|
||||
try {
|
||||
const client = nextcloudClient();
|
||||
const books = await client.listAddressBooks();
|
||||
if (books.length === 0) return res.json({ addressbooks: [], contacts: [] });
|
||||
const wanted = clean(req.query.addressbook, 500);
|
||||
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||||
const cards = await client.queryContacts(book.href);
|
||||
const st = S();
|
||||
const contacts = [];
|
||||
for (const raw of cards) {
|
||||
for (const c of parseVCards(raw)) {
|
||||
if (!c.firstName && !c.lastName) continue;
|
||||
const existing = st.persons.find((p) => (c.email && p.email === c.email) || (p.firstName.toLowerCase() === c.firstName.toLowerCase() && p.lastName.toLowerCase() === c.lastName.toLowerCase()));
|
||||
contacts.push({ uid: c.uid, firstName: c.firstName, lastName: c.lastName, email: c.email, hasPhoto: Boolean(c.photo) || book.system, existingId: existing?.id || null });
|
||||
}
|
||||
}
|
||||
contacts.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`, 'de'));
|
||||
res.json({ addressbooks: books, addressbook: book.href, contacts });
|
||||
} catch (err) { ncError(res, err); }
|
||||
});
|
||||
|
||||
/** Ausgewählte Kontakte als Personen anlegen, Fotos mitnehmen. */
|
||||
app.post('/api/nextcloud/contacts/import', async (req, res) => {
|
||||
const uids = Array.isArray(req.body?.uids) ? req.body.uids.map((u) => clean(u, 200)).filter(Boolean) : [];
|
||||
if (uids.length === 0) return bad(res, 'Keine Kontakte ausgewählt.');
|
||||
try {
|
||||
const client = nextcloudClient();
|
||||
const books = await client.listAddressBooks();
|
||||
const wanted = clean(req.body.addressbook, 500);
|
||||
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||||
if (!book) return bad(res, 'Kein Adressbuch verfügbar.');
|
||||
const cards = (await client.queryContacts(book.href)).flatMap(parseVCards);
|
||||
const st = S();
|
||||
const created = [];
|
||||
const photos = [];
|
||||
const skipped = [];
|
||||
for (const uid of uids) {
|
||||
const c = cards.find((x) => x.uid === uid);
|
||||
if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; }
|
||||
const r = readPerson({ firstName: c.firstName || c.lastName, lastName: c.lastName || c.firstName, email: c.email, note: '' });
|
||||
if (r.error) { skipped.push(uid); continue; }
|
||||
const person = { id: randomUUID(), ...r.person, photoVersion: 0, nextcloudUid: uid };
|
||||
st.persons.push(person);
|
||||
created.push(person.id);
|
||||
let img = null;
|
||||
if (book.system) img = await client.fetchAvatar(uid).catch(() => null);
|
||||
if (!img && c.photo?.kind === 'inline') { const buffer = Buffer.from(c.photo.base64, 'base64'); if (imageMime(buffer)) img = { buffer }; }
|
||||
if (!img && c.photo?.kind === 'url') img = await client.fetchBinary(c.photo.url).catch(() => null);
|
||||
if (img && imageMime(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); }
|
||||
}
|
||||
store.save();
|
||||
res.json({ created: created.length, withPhoto: photos.length, skipped: skipped.length, persons: st.persons.map(publicPerson) });
|
||||
} catch (err) { ncError(res, err); }
|
||||
});
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Unbekannter Endpunkt.' }));
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) return next(err);
|
||||
const status = err.status || err.statusCode || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Interner Fehler.' : err.message });
|
||||
if (status === 500) console.error(err);
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
app.listen(port, () => {
|
||||
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Reine Planungslogik ohne Abhängigkeiten. Datumsangaben sind immer ISO-Strings (YYYY-MM-DD).
|
||||
|
||||
export function addDays(iso, n) {
|
||||
const d = new Date(iso + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isWeekend(iso) {
|
||||
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
/** ISO-Wochentag 1 (Mo) … 7 (So). */
|
||||
export function isoWeekday(iso) {
|
||||
const day = new Date(iso + 'T00:00:00Z').getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
export function isValidDate(iso) {
|
||||
return typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso) && !Number.isNaN(Date.parse(iso + 'T00:00:00Z'))
|
||||
&& new Date(iso + 'T00:00:00Z').toISOString().slice(0, 10) === iso;
|
||||
}
|
||||
|
||||
/** Alle Werktage (Mo–Fr) im Bereich, ohne die blockierten Tage (Feiertage, betriebsfrei). */
|
||||
export function workingDays(from, to, blocked = new Set()) {
|
||||
const out = [];
|
||||
for (let d = from; d <= to; d = addDays(d, 1)) {
|
||||
if (!isWeekend(d) && !blocked.has(d)) out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isAbsent(personId, date, absences) {
|
||||
return absences.some((a) => a.personId === personId && a.from <= date && a.to >= date);
|
||||
}
|
||||
|
||||
/** Verfügbar = aktiv, nicht abwesend und kein fester freier Wochentag (Teilzeit). */
|
||||
export function isAvailable(person, date, absences) {
|
||||
if (person.active === false) return false;
|
||||
if (Array.isArray(person.freeDays) && person.freeDays.includes(isoWeekday(date))) return false;
|
||||
return !isAbsent(person.id, date, absences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt Zuteilungen für die angegebenen Tage.
|
||||
*
|
||||
* - persons: [{ id, canMain, canCo, active, freeDays }]
|
||||
* - absences: [{ personId, from, to }]
|
||||
* - existing: { date: { main, co, locked, off, keep, fill } } – locked/off/keep bleiben unverändert,
|
||||
* fill: vorhandene Slots behalten, nur leere auslosen
|
||||
* - blocked: Set blockierter Tage (Feiertage), damit „nicht zwei Werktage in Folge" darüber hinweg gilt
|
||||
* - history: { date: { main, co, off } } – frühere Zuteilungen für die Fairness-Zählung
|
||||
* - rng: Zufallsfunktion [0,1), austauschbar für Tests
|
||||
*
|
||||
* Fairness: Wer im Betrachtungsfenster am seltensten dran war, wird bevorzugt; unter Gleichen
|
||||
* entscheidet die Rollenverteilung, danach der Zufall. Niemand moderiert an zwei Werktagen
|
||||
* hintereinander, wenn es sich vermeiden lässt, und dasselbe Paar wiederholt sich ungern.
|
||||
*/
|
||||
export function generateSchedule({ dates, persons, absences = [], existing = {}, history = {}, blocked = new Set(), rng = Math.random, historyWindowDays = 60 }) {
|
||||
const result = {};
|
||||
const counts = new Map();
|
||||
const stat = (id) => {
|
||||
if (!counts.has(id)) counts.set(id, { total: 0, main: 0, co: 0, lastPartner: null });
|
||||
return counts.get(id);
|
||||
};
|
||||
const bump = (id, role, partner) => {
|
||||
const s = stat(id);
|
||||
s.total += 1;
|
||||
s[role] += 1;
|
||||
s.lastPartner = partner || null;
|
||||
};
|
||||
|
||||
const first = dates[0];
|
||||
if (first) {
|
||||
const windowStart = addDays(first, -historyWindowDays);
|
||||
const past = Object.entries(history).filter(([date, a]) => date < first && date >= windowStart && a && !a.off).sort();
|
||||
for (const [, a] of past) {
|
||||
if (a.main) bump(a.main, 'main', a.co);
|
||||
if (a.co) bump(a.co, 'co', a.main);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedOn = (date) => {
|
||||
const a = result[date] || history[date];
|
||||
return a && !a.off ? [a.main, a.co].filter(Boolean) : [];
|
||||
};
|
||||
const previousWorkingDay = (date) => {
|
||||
let d = addDays(date, -1);
|
||||
while (isWeekend(d) || blocked.has(d)) d = addDays(d, -1);
|
||||
return d;
|
||||
};
|
||||
|
||||
const active = persons.filter((p) => p.active !== false);
|
||||
|
||||
for (const date of dates) {
|
||||
const ex = existing[date];
|
||||
if (ex && (ex.locked || ex.off || ex.keep)) {
|
||||
const { keep, fill, ...rest } = ex;
|
||||
result[date] = { ...rest };
|
||||
if (!ex.off) {
|
||||
if (ex.main) bump(ex.main, 'main', ex.co);
|
||||
if (ex.co) bump(ex.co, 'co', ex.main);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const fixedMain = ex && ex.fill ? ex.main || null : null;
|
||||
const fixedCo = ex && ex.fill ? ex.co || null : null;
|
||||
const yesterday = assignedOn(previousWorkingDay(date));
|
||||
const available = active.filter((p) => isAvailable(p, date, absences));
|
||||
const main = fixedMain || pick(available.filter((p) => p.canMain && p.id !== fixedCo), 'main', yesterday, null, stat, rng);
|
||||
const co = fixedCo || pick(available.filter((p) => p.canCo && p.id !== main), 'co', yesterday, main, stat, rng);
|
||||
result[date] = { main, co, locked: false };
|
||||
if (main) bump(main, 'main', co);
|
||||
if (co) bump(co, 'co', main);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pick(candidates, role, yesterday, partner, stat, rng) {
|
||||
if (candidates.length === 0) return null;
|
||||
let pool = candidates.filter((p) => !yesterday.includes(p.id));
|
||||
if (pool.length === 0) pool = candidates;
|
||||
|
||||
const minTotal = Math.min(...pool.map((p) => stat(p.id).total));
|
||||
pool = pool.filter((p) => stat(p.id).total === minTotal);
|
||||
|
||||
const minRole = Math.min(...pool.map((p) => stat(p.id)[role]));
|
||||
pool = pool.filter((p) => stat(p.id)[role] === minRole);
|
||||
|
||||
if (partner) {
|
||||
const fresh = pool.filter((p) => stat(p.id).lastPartner !== partner);
|
||||
if (fresh.length > 0) pool = fresh;
|
||||
}
|
||||
return pool[Math.floor(rng() * pool.length)].id;
|
||||
}
|
||||
|
||||
/** Zählt Einsätze je Person im Bereich – für die Statistik in der Oberfläche. */
|
||||
export function countAssignments(assignments, from, to) {
|
||||
const out = {};
|
||||
for (const [date, a] of Object.entries(assignments)) {
|
||||
if (date < from || date > to || !a || a.off) continue;
|
||||
for (const [role, id] of [['main', a.main], ['co', a.co]]) {
|
||||
if (!id) continue;
|
||||
out[id] = out[id] || { main: 0, co: 0, total: 0 };
|
||||
out[id][role] += 1;
|
||||
out[id].total += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_STATE = () => ({
|
||||
persons: [],
|
||||
absences: [],
|
||||
holidays: [],
|
||||
assignments: {},
|
||||
exported: {},
|
||||
settings: {
|
||||
meetingTime: '09:30',
|
||||
durationMinutes: 15,
|
||||
bundesland: 'BY',
|
||||
calendarDisplayName: 'Morning Meeting',
|
||||
inviteAttendees: false,
|
||||
historyWindowDays: 60,
|
||||
holidaysSeeded: false,
|
||||
targetCalendarHref: '',
|
||||
targetCalendarName: '',
|
||||
absenceCalendarHref: '',
|
||||
absenceCalendarName: '',
|
||||
},
|
||||
});
|
||||
|
||||
/** Bildtyp anhand der ersten Bytes erkennen. */
|
||||
export function imageMime(buffer) {
|
||||
if (!buffer || buffer.length < 12) return null;
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg';
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png';
|
||||
if (buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
|
||||
if (buffer.subarray(0, 4).toString('ascii') === 'GIF8') return 'image/gif';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Einfacher JSON-Speicher mit atomarem Schreiben. Fotos liegen daneben im Ordner photos/. */
|
||||
export function createStore(dataDir) {
|
||||
const file = path.join(dataDir, 'rotatory.json');
|
||||
const photoDir = path.join(dataDir, 'photos');
|
||||
fs.mkdirSync(photoDir, { recursive: true });
|
||||
|
||||
let state = DEFAULT_STATE();
|
||||
if (fs.existsSync(file)) {
|
||||
const loaded = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
state = { ...state, ...loaded, settings: { ...state.settings, ...(loaded.settings || {}) } };
|
||||
}
|
||||
|
||||
function save() {
|
||||
const tmp = file + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
const photoPath = (id) => path.join(photoDir, `${id}.jpg`);
|
||||
return {
|
||||
get state() { return state; },
|
||||
save,
|
||||
photoPath,
|
||||
hasPhoto: (id) => fs.existsSync(photoPath(id)),
|
||||
readPhoto: (id) => (fs.existsSync(photoPath(id)) ? fs.readFileSync(photoPath(id)) : null),
|
||||
savePhoto: (id, buffer) => fs.writeFileSync(photoPath(id), buffer),
|
||||
deletePhoto: (id) => { try { fs.unlinkSync(photoPath(id)); } catch {} },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user