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

+612
View File
@@ -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'}`);
});