const { Worship, EventPlace, LiturgicalDay, WorshipLeader, Sequelize, sequelize } = require('../models'); const { Op, fn, literal } = require('sequelize'); const jwt = require('jsonwebtoken'); const { isTokenBlacklisted, addTokenToBlacklist } = require('../utils/blacklist'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); const mammoth = require('mammoth'); const pdfParse = require('pdf-parse'); const { parse: parseCsv } = require('csv-parse/sync'); const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, WidthType, AlignmentType, VerticalAlign, ShadingType, VerticalMerge, VerticalMergeType, FontFamily, HeadingLevel, PageMargin, SectionType, BorderStyle, HeightRule } = require('docx'); function isAuthorized(req) { const authHeader = req.header('Authorization'); if (!authHeader) { return false; } const token = authHeader.replace('Bearer ', ''); if (isTokenBlacklisted(token)) { console.log('Token is blacklisted'); return false; } try { const decoded = jwt.verify(token, 'zTxVgptmPl9!_dr%xxx9999(dd)'); req.user = decoded; return true; } catch (err) { // Token ist ungültig oder abgelaufen – Benutzer gilt einfach als nicht autorisiert. // Wichtig: Wir setzen abgelaufene/ungültige Tokens hier NICHT mehr auf die Blacklist, // damit ein Seiten-Reload nicht dazu führt, dass der Token als "gesperrt" behandelt wird. console.log('Token verification failed:', err.message); return false; } } exports.getAllWorships = async (req, res) => { try { const authorized = isAuthorized(req); const worships = await Worship.findAll({ where: { date: { [Op.gt]: literal("DATE_SUB(NOW(), INTERVAL 4 WEEK)") }, }, attributes: authorized ? undefined : { exclude: ['sacristanService'] }, order: [ ['date', 'ASC'], ['time', 'ASC'] ], }); res.status(200).json(worships); } catch (error) { console.error('Fehler beim Abrufen der Gottesdienste:', error); res.status(500).json({ message: 'Fehler beim Abrufen der Gottesdienste', error: error.message }); } }; exports.createWorship = async (req, res) => { try { const worship = await Worship.create(req.body); res.status(201).json(worship); } catch (error) { console.log(error); res.status(500).json({ message: 'Fehler beim Erstellen des Gottesdienstes' }); } }; exports.updateWorship = async (req, res) => { try { const worship = await Worship.findByPk(req.params.id); if (worship) { await worship.update(req.body); res.status(200).json(worship); } else { res.status(404).json({ message: 'Gottesdienst nicht gefunden' }); } } catch (error) { res.status(500).json({ message: 'Fehler beim Aktualisieren des Gottesdienstes' }); } }; exports.deleteWorship = async (req, res) => { try { const worship = await Worship.findByPk(req.params.id); if (worship) { await worship.destroy(); res.status(200).json({ message: 'Gottesdienst erfolgreich gelöscht' }); } else { res.status(404).json({ message: 'Gottesdienst nicht gefunden' }); } } catch (error) { res.status(500).json({ message: 'Fehler beim Löschen des Gottesdienstes' }); } }; exports.getFilteredWorships = async (req, res) => { const { location, order, neighborInvitation } = req.query; const where = {}; if (order && order.trim() === '') { order = 'date DESC'; } const locations = location ? JSON.parse(location) : []; if (location && locations.length > 0) { where.eventPlaceId = { [Sequelize.Op.in]: locations } } const wantsNeighborhood = String(neighborInvitation || '').toLowerCase() === 'true'; if (wantsNeighborhood) { where.neighborInvitation = true; } where.date = { [Op.gte]: fn('CURDATE'), }; // Nur freigegebene Gottesdienste anzeigen where.approved = true; try { const authorized = isAuthorized(req); // Attribute: organPlaying und sacristanService nur für nicht-autorisierte Benutzer ausschließen const attributesExclude = []; if (!authorized) { attributesExclude.push('organPlaying', 'sacristanService'); } const worships = await Worship.findAll({ where, attributes: { exclude: attributesExclude }, include: { model: EventPlace, as: 'eventPlace', }, order: [ ['date', 'ASC'], ['time', 'ASC'] ], }); res.status(200).json(worships); } catch (error) { console.log(error); res.status(500).json({ message: 'Fehler beim Abrufen der gefilterten Gottesdienste' }); } }; exports.getWorshipOptions = async (req, res) => { try { // Alle Worships mit organizer und sacristanService abrufen const worships = await Worship.findAll({ attributes: ['organizer', 'sacristanService'], raw: true }); // Strings aufteilen (kommasepariert) und alle eindeutigen Werte sammeln const organizerSet = new Set(); const sacristanSet = new Set(); worships.forEach(worship => { // Organizer verarbeiten if (worship.organizer && worship.organizer.trim() !== '') { worship.organizer.split(',').forEach(org => { const trimmed = org.trim(); if (trimmed) organizerSet.add(trimmed); }); } // SacristanService verarbeiten if (worship.sacristanService && worship.sacristanService.trim() !== '') { worship.sacristanService.split(',').forEach(sac => { const trimmed = sac.trim(); if (trimmed) sacristanSet.add(trimmed); }); } }); res.status(200).json({ organizers: Array.from(organizerSet).sort(), sacristanServices: Array.from(sacristanSet).sort() }); } catch (error) { console.error('Fehler beim Abrufen der Worship-Optionen:', error); res.status(500).json({ message: 'Fehler beim Abrufen der Worship-Optionen', error: error.message }); } }; // Multer middleware für File-Upload exports.uploadImportFile = upload.single('file'); // Hilfsfunktion zum Parsen eines Datums aus dem Tag-String function parseDateFromDayString(dayString) { // Erwartetes Format: "24.08.2025 - 1. Advent" oder "24.08.2025" const dateMatch = dayString.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); if (dateMatch) { const day = parseInt(dateMatch[1], 10); const month = parseInt(dateMatch[2], 10) - 1; // Monate sind 0-indexiert const year = parseInt(dateMatch[3], 10); // Erstelle Date-Objekt mit UTC, um Zeitzonenprobleme zu vermeiden const date = new Date(Date.UTC(year, month, day)); return date; } return null; } // Hilfsfunktion zum Extrahieren des Tag-Namens function extractDayName(dayString) { // Erwartetes Format: "24.08.2025 - 1. Advent" oder "24.08.2025\n10. Sonntag nach Trinitatis" // Zuerst versuche mit " - " Trennzeichen const parts = dayString.split(' - '); if (parts.length > 1) { return parts.slice(1).join(' - ').trim(); } // Falls nicht gefunden, versuche mit Zeilenumbrüchen const lines = dayString.split('\n').map(line => line.trim()).filter(line => line.length > 0); if (lines.length > 1) { // Erste Zeile ist das Datum, weitere Zeilen sind der Tag-Name return lines.slice(1).join(' ').trim(); } return ''; } // Hilfsfunktion zum Parsen der Uhrzeit function parseTime(timeString) { // Erwartetes Format: "10:00", "10.00", "10:00 Uhr" oder "10.00 Uhr" // Versuche zuerst mit Doppelpunkt let timeMatch = timeString.match(/(\d{1,2}):(\d{2})/); if (!timeMatch) { // Versuche mit Punkt timeMatch = timeString.match(/(\d{1,2})\.(\d{2})/); } if (timeMatch) { const hours = parseInt(timeMatch[1], 10); const minutes = parseInt(timeMatch[2], 10); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; } return null; } function parseGermanDateString(dateString) { const value = String(dateString || '').trim(); const match = value.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); if (!match) return null; const day = parseInt(match[1], 10); const month = parseInt(match[2], 10) - 1; const year = parseInt(match[3], 10); return new Date(Date.UTC(year, month, day)); } function normalizeText(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function buildLeaderMaps(leaders) { const codeToName = new Map(); const normalizedToName = new Map(); for (const leader of leaders || []) { if (!leader?.active) continue; const code = normalizeText(leader.code); const name = normalizeText(leader.name); if (code) { codeToName.set(code, name); normalizedToName.set(code.toLowerCase(), name); } const aliases = String(leader.aliases || '') .split(',') .map((x) => normalizeText(x)) .filter(Boolean); for (const alias of aliases) { normalizedToName.set(alias.toLowerCase(), name); } } return { codeToName, normalizedToName }; } function resolveEventPlaceIdFromHeader(eventPlaces, headerCell) { const raw = normalizeText(headerCell); if (!raw) return null; const nameOnly = normalizeText(raw.split('(')[0]); const normalized = nameOnly.toLowerCase(); // Prefer exact name match. const exact = (eventPlaces || []).find((p) => normalizeText(p.name).toLowerCase() === normalized); if (exact) return exact.id; // Fallback: contains. const contains = (eventPlaces || []).find((p) => normalizeText(p.name).toLowerCase().includes(normalized)); if (contains) return contains.id; // Hardcoded fallbacks for known CSV headers. if (/am b[üu]gel/i.test(raw)) return 12; if (/bonames/i.test(raw)) return 7; if (/kalbach/i.test(raw)) return 2; if (/nieder-eschbach/i.test(raw)) return 14; if (/harheim/i.test(raw)) return 15; if (/nieder-erlenbach/i.test(raw)) return 13; return null; } function splitNbrCellToSegments(cellText) { const text = normalizeText(cellText); if (!text) return []; // Often multiple items are separated by newlines or commas; keep it conservative. return text .split(/\n+|,\s*(?=(?:[A-ZÄÖÜa-zäöü]{1,3}\.)?\s*\d{1,2}[:.]\d{2}|Sa\.)/g) .map((s) => normalizeText(s)) .filter(Boolean); } function parseNbrSegment(segment, baseDateUtc, leaderNormalizedMap) { const raw = normalizeText(segment); if (!raw) return null; let dateUtc = baseDateUtc; let text = raw; if (/^(sa|samstag)\.?/i.test(text)) { const d = new Date(dateUtc.getTime()); d.setUTCDate(d.getUTCDate() - 1); dateUtc = d; text = normalizeText(text.replace(/^(sa|samstag)\.?\s*/i, '')); } // Time: allow 10.30, 10:30, 10 h, 10h, 10.30 Uhr, etc. let time = null; const timeMatch = text.match(/(\d{1,2})\s*(?:[:.]\s*(\d{2})|h)\b/i); if (timeMatch) { const hours = parseInt(timeMatch[1], 10); const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0; time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; text = normalizeText(text.replace(timeMatch[0], '')); } // Officiant: pick the last token that matches a configured leader code/alias. let officiant = ''; const tokens = text.split(' ').map((t) => t.trim()).filter(Boolean); for (let i = tokens.length - 1; i >= 0; i--) { const token = tokens[i].replace(/[()]/g, ''); const resolved = leaderNormalizedMap.get(token.toLowerCase()); if (resolved) { officiant = resolved; tokens.splice(i, 1); break; } } const title = tokens.join(' ').trim() || 'Gottesdienst'; return { dateUtc, time, title, officiant }; } // Hilfsfunktion zum Parsen eines Gottesdienstes aus der zweiten Spalte function parseWorshipFromCell(cellText, date, dayName) { // Zuerst in Zeilen aufteilen (falls Zeilenumbrüche vorhanden) const lines = cellText.split('\n').map(line => line.trim()).filter(line => line.length > 0); if (lines.length === 0) { return null; } // Für Debugging: Zeige die Zeilen console.log(` parseWorshipFromCell: ${lines.length} Zeilen gefunden`); lines.forEach((line, idx) => { console.log(` Zeile ${idx + 1}: "${line.substring(0, 100)}${line.length > 100 ? '...' : ''}"`); }); const fullText = cellText.trim(); const hasNeighborInvitation = /einladung zum gottesdienst im nachbarschaftsraum/i.test(fullText) || /\[\[FLAG_NEIGHBOR_INVITATION\]\]/.test(fullText); const hasSelfInformation = /bitte informieren sie sich auch auf den internetseiten/i.test(fullText) || /\[\[FLAG_SELF_INFORMATION\]\]/.test(fullText); // Wenn Zeilenumbrüche vorhanden sind, verwende die zeilenbasierte Logik if (lines.length > 1) { return parseWorshipFromCellWithLines(lines, date, dayName); } const worship = { date: date, dayName: dayName, time: null, title: '', organizer: '', sacristanService: '', collection: '', organPlaying: '', eventPlaceId: null, address: '', selfInformation: false, highlightTime: false, neighborInvitation: false, introLine: '' }; worship.neighborInvitation = hasNeighborInvitation; worship.selfInformation = hasSelfInformation; console.log(` parseWorshipFromCell: Volltext: "${fullText.substring(0, 200)}..."`); // Suche nach Uhrzeit am Anfang (Format: "11.15 Uhr" oder "11:15 Uhr") const timeMatch = fullText.match(/^(\d{1,2})[:.](\d{2})\s*Uhr/i); if (timeMatch) { const hours = parseInt(timeMatch[1], 10); const minutes = parseInt(timeMatch[2], 10); worship.time = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; console.log(` parseWorshipFromCell: Uhrzeit geparst: ${worship.time}`); // Text nach der Uhrzeit extrahieren const textAfterTime = fullText.substring(timeMatch[0].length).trim(); // Titel extrahieren: Alles bis zum ersten "Gestaltung:", "Dienst:", "Kollekte:" oder "Orgel:" const titleEndMatch = textAfterTime.match(/(Gestaltung|Dienst|Kollekte|Orgel|Bitte informieren):/i); if (titleEndMatch) { let title = textAfterTime.substring(0, titleEndMatch.index).trim(); // Entferne häufige Wörter am Anfang title = title.replace(/^(Gottesdienst|Gemeinsamer Gottesdienst|Einladung zum Gottesdienst)\s*/i, ''); // Entferne "in", "am", "zu" + Ort am Ende, wenn vorhanden (aber behalte den Rest) title = title.replace(/\s+(in|am|zu)\s+([A-ZÄÖÜ][A-ZÄÖÜa-zäöüß-]+)$/, ''); title = title .replace(/\[\[FLAG_NEIGHBOR_INVITATION\]\]/g, '') .replace(/\[\[FLAG_SELF_INFORMATION\]\]/g, '') .replace(/bitte informieren sie sich auch auf den internetseiten.*$/i, '') .replace(/\|/g, ' ') .replace(/\s+/g, ' ') .trim(); worship.title = title || 'Gottesdienst'; } else { // Falls keine Markierungen gefunden, nimm den gesamten Text als Titel let title = textAfterTime; title = title.replace(/^(Gottesdienst|Gemeinsamer Gottesdienst|Einladung zum Gottesdienst)\s*/i, ''); title = title .replace(/\[\[FLAG_NEIGHBOR_INVITATION\]\]/g, '') .replace(/\[\[FLAG_SELF_INFORMATION\]\]/g, '') .replace(/bitte informieren sie sich auch auf den internetseiten.*$/i, '') .replace(/\|/g, ' ') .replace(/\s+/g, ' ') .trim(); worship.title = title.substring(0, 140) || 'Gottesdienst'; } console.log(` parseWorshipFromCell: Titel extrahiert: "${worship.title}"`); // Ort extrahieren (aus dem Titel-Bereich) const locationMatch = textAfterTime.match(/(?:in|am|zu)\s+([A-ZÄÖÜ][A-ZÄÖÜa-zäöüß-]+)/i); if (locationMatch) { const locationName = locationMatch[1].trim(); const normalizedLocation = locationName.split(/\s*-\s*/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() ).join('-'); worship.extractedLocation = `Kirche ${normalizedLocation}`; console.log(` parseWorshipFromCell: Ort extrahiert: "${worship.extractedLocation}"`); } // Gestalter extrahieren const organizerMatch = fullText.match(/Gestaltung:\s*([^DienstKollekteOrgelVideoschnitt]+?)(?=Dienst:|Kollekte:|Orgel:|Videoschnitt:|$)/i); if (organizerMatch) { worship.organizer = organizerMatch[1].trim(); console.log(` parseWorshipFromCell: Gestalter extrahiert: "${worship.organizer}"`); } // Dienst extrahieren const serviceMatch = fullText.match(/Dienst:\s*([^KollekteOrgelVideoschnitt]+?)(?=Kollekte:|Orgel:|Videoschnitt:|$)/i); if (serviceMatch) { worship.sacristanService = serviceMatch[1].trim(); console.log(` parseWorshipFromCell: Dienst extrahiert: "${worship.sacristanService}"`); } // Kollekte extrahieren const collectionMatch = fullText.match(/Kollekte:\s*([^OrgelVideoschnitt]+?)(?=Orgel:|Videoschnitt:|$)/i); if (collectionMatch) { let collection = collectionMatch[1].trim(); // Falls am Ende eine Nummer in Klammern steht (z.B. "für XY (12345)"), diese entfernen collection = collection.replace(/\s*\(\d+\)\s*$/, '').trim(); worship.collection = collection; console.log(` parseWorshipFromCell: Kollekte extrahiert: "${worship.collection}"`); } // Orgelspiel extrahieren const organMatch = fullText.match(/Orgel:\s*([^GestaltungDienstKollekteVideoschnitt]+?)(?=Gestaltung:|Dienst:|Kollekte:|Videoschnitt:|$)/i); if (organMatch) { worship.organPlaying = organMatch[1].trim(); console.log(` parseWorshipFromCell: Orgelspiel extrahiert: "${worship.organPlaying}"`); } } else { // Falls keine Uhrzeit gefunden, versuche Titel direkt zu extrahieren const titleMatch = fullText.match(/^(.+?)(?=Gestaltung:|Dienst:|Kollekte:|Orgel:|$)/i); if (titleMatch) { worship.title = titleMatch[1] .replace(/\[\[FLAG_NEIGHBOR_INVITATION\]\]/g, '') .replace(/\[\[FLAG_SELF_INFORMATION\]\]/g, '') .replace(/bitte informieren sie sich auch auf den internetseiten.*$/i, '') .replace(/\|/g, ' ') .replace(/\s+/g, ' ') .trim(); } else { worship.title = fullText.substring(0, 100); } } // Nur Datum ist Pflichtfeld - alle anderen Felder sind optional // Das Datum wird bereits vor dem Aufruf dieser Funktion geprüft // Falls keine Uhrzeit gefunden wurde, setze einen Standardwert oder lasse es leer if (!worship.time) { worship.time = null; // Optional } // Falls kein Titel gefunden wurde, setze einen Standardwert if (!worship.title || worship.title.trim().length === 0) { worship.title = 'Gottesdienst'; // Standardtitel } console.log(` parseWorshipFromCell: Erfolgreich - date: ${worship.date}, time: ${worship.time || 'keine'}, title: "${worship.title}"`); return worship; } // Hilfsfunktion zum Parsen eines Gottesdienstes mit Zeilenumbrüchen function parseWorshipFromCellWithLines(lines, date, dayName) { const worship = { date: date, dayName: dayName, time: null, title: '', organizer: '', sacristanService: '', collection: '', organPlaying: '', eventPlaceId: null, address: '', selfInformation: false, highlightTime: false, neighborInvitation: false, introLine: '' }; // Suche nach Uhrzeit in allen Zeilen (beginne mit der ersten) let timeFound = false; let timeLineIndex = -1; for (let i = 0; i < lines.length; i++) { const time = parseTime(lines[i]); if (time) { worship.time = time; timeLineIndex = i; timeFound = true; console.log(` parseWorshipFromCellWithLines: Uhrzeit geparst: ${worship.time} (Zeile ${i + 1})`); break; } } if (!timeFound) { // Keine Uhrzeit gefunden return null; } // Titel aus der Zeile mit der Uhrzeit extrahieren if (timeLineIndex >= 0 && lines[timeLineIndex]) { let timeLine = lines[timeLineIndex]; let title = timeLine.replace(/\d{1,2}[:.]\d{2}/, '').replace(/Uhr/gi, '').trim(); // Entferne häufige Wörter am Anfang, aber behalte "Gottesdienst" title = title.replace(/^(Gemeinsamer|Einladung zum)\s+/i, ''); // Entferne Ort am Ende (wenn vorhanden) title = title.replace(/\s+(?:in|am|zu)\s+[A-ZÄÖÜ][A-ZÄÖÜa-zäöüß-]+$/i, ''); // Falls Titel leer ist, verwende "Gottesdienst" worship.title = title.trim() || 'Gottesdienst'; // Ort aus der Zeile mit der Uhrzeit extrahieren const locationMatch = timeLine.match(/(?:in|am|zu)\s+([A-ZÄÖÜ][A-ZÄÖÜa-zäöüß-]+)/i); if (locationMatch) { const locationName = locationMatch[1].trim(); const normalizedLocation = locationName.split(/\s*-\s*/).map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() ).join('-'); worship.extractedLocation = `Kirche ${normalizedLocation}`; console.log(` parseWorshipFromCellWithLines: Ort extrahiert: "${worship.extractedLocation}"`); } } // Weitere Zeilen durchgehen (beginne nach der Zeile mit der Uhrzeit) for (let i = timeLineIndex + 1; i < lines.length; i++) { const line = lines[i]; // Wenn eine neue Uhrzeit gefunden wird, stoppe hier (dieser Block gehört zu einem anderen Gottesdienst) if (parseTime(line)) { break; } if (/einladung zum gottesdienst im nachbarschaftsraum/i.test(line) || /\[\[FLAG_NEIGHBOR_INVITATION\]\]/.test(line)) { worship.neighborInvitation = true; continue; } if (/bitte informieren sie sich auch auf den internetseiten/i.test(line) || /\[\[FLAG_SELF_INFORMATION\]\]/.test(line)) { worship.selfInformation = true; continue; } // Gestalter if (line.toLowerCase().includes('gestaltung:')) { worship.organizer = line.replace(/^.*gestaltung:\s*/i, '').trim(); console.log(` parseWorshipFromCellWithLines: Gestalter: "${worship.organizer}"`); } // Dienst else if (line.toLowerCase().includes('dienst:') && !line.toLowerCase().includes('gestaltung')) { worship.sacristanService = line.replace(/^.*dienst:\s*/i, '').trim(); console.log(` parseWorshipFromCellWithLines: Dienst: "${worship.sacristanService}"`); } // Kollekte else if (line.toLowerCase().includes('kollekte:')) { let collection = line.replace(/^.*kollekte:\s*/i, '').trim(); // Falls am Ende eine Nummer in Klammern steht (z.B. "für XY (12345)"), diese entfernen collection = collection.replace(/\s*\(\d+\)\s*$/, '').trim(); worship.collection = collection; console.log(` parseWorshipFromCellWithLines: Kollekte: "${worship.collection}"`); } // Orgelspiel else if (line.toLowerCase().includes('orgel:')) { worship.organPlaying = line.replace(/^.*orgel:\s*/i, '').trim(); console.log(` parseWorshipFromCellWithLines: Orgelspiel: "${worship.organPlaying}"`); } // Falls keine spezifische Markierung und Titel noch nicht vollständig else if (line && !worship.title.includes(line) && !line.toLowerCase().includes('videoschnitt')) { if (worship.title && !worship.title.includes(line)) { worship.title += ' ' + line; } } } worship.title = worship.title .replace(/\[\[FLAG_NEIGHBOR_INVITATION\]\]/g, '') .replace(/\[\[FLAG_SELF_INFORMATION\]\]/g, '') .replace(/\|/g, ' ') .replace(/\s+/g, ' ') .trim(); // Mindestanforderungen prüfen if (!worship.time || !worship.title) { console.log(` parseWorshipFromCellWithLines: Fehlgeschlagen - time: ${worship.time}, title: "${worship.title}"`); return null; } console.log(` parseWorshipFromCellWithLines: Erfolgreich - time: ${worship.time}, title: "${worship.title}"`); return worship; } // Import-Funktion für Gottesdienste aus .doc/.docx Dateien exports.importWorships = async (req, res) => { try { if (!req.file) { return res.status(400).json({ message: 'Keine Datei hochgeladen.' }); } // Validierung: Nur .doc und .docx Dateien erlauben const fileName = req.file.originalname.toLowerCase(); const allowedExtensions = ['.doc', '.docx']; const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext)); if (!isValidFile) { return res.status(400).json({ message: 'Nur .doc und .docx Dateien sind erlaubt.' }); } // Nur .docx wird aktuell unterstützt (mammoth unterstützt nur .docx) if (!fileName.endsWith('.docx')) { return res.status(400).json({ message: 'Aktuell werden nur .docx Dateien unterstützt.' }); } // .docx Datei mit mammoth parsen const result = await mammoth.convertToHtml({ buffer: req.file.buffer }); const html = result.value; // Tabelle aus HTML extrahieren const tableRegex = /]*>([\s\S]*?)<\/table>/gi; const tableMatch = tableRegex.exec(html); if (!tableMatch) { return res.status(400).json({ message: 'Keine Tabelle in der Datei gefunden.' }); } const tableHtml = tableMatch[1]; // Zeilen aus der Tabelle extrahieren const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; const rows = []; let rowMatch; while ((rowMatch = rowRegex.exec(tableHtml)) !== null) { rows.push(rowMatch[1]); } const importedWorships = []; const errors = []; // Heutiges Datum (ohne Uhrzeit) zur Filterung von Vergangenheits-Terminen const today = new Date(); today.setHours(0, 0, 0, 0); console.log(`Gefundene Tabellenzeilen: ${rows.length}`); // Durch jede Zeile iterieren for (const row of rows) { // Zellen aus der Zeile extrahieren const cellRegex = /]*>([\s\S]*?)<\/t[dh]>/gi; const cells = []; let cellMatch; while ((cellMatch = cellRegex.exec(row)) !== null) { // HTML-Tags entfernen und Text extrahieren, aber Zeilenumbrüche erhalten let cellText = cellMatch[1]; // Ersetze
und
durch Zeilenumbrüche cellText = cellText.replace(//gi, '\n'); // Ersetze

und

durch Zeilenumbrüche (aber nicht doppelte) cellText = cellText.replace(/<\/p>/gi, '\n'); cellText = cellText.replace(/]*>/gi, ''); // Ersetze und

durch Zeilenumbrüche cellText = cellText.replace(/<\/div>/gi, '\n'); cellText = cellText.replace(/]*>/gi, ''); // Entferne andere HTML-Tags cellText = cellText.replace(/<[^>]+>/g, ''); // HTML-Entities ersetzen cellText = cellText.replace(/ /g, ' '); cellText = cellText.replace(/&/g, '&'); cellText = cellText.replace(/</g, '<'); cellText = cellText.replace(/>/g, '>'); cellText = cellText.replace(/"/g, '"'); // Mehrfache Zeilenumbrüche auf einen reduzieren cellText = cellText.replace(/\n\s*\n\s*\n+/g, '\n\n'); // Trim cellText = cellText.trim(); cells.push(cellText); } // Mindestens 2 Spalten erwartet if (cells.length < 2) { continue; } const dayString = cells[0]; const worshipCell = cells[1]; console.log(`Zeile ${rows.indexOf(row) + 1}: dayString="${dayString}", worshipCell="${worshipCell.substring(0, 100)}..."`); // Wenn Spalte 1 leer ist, Zeile überspringen if (!dayString || dayString.trim().length === 0) { console.log(` -> Spalte 1 ist leer, Zeile wird übersprungen`); continue; } // Datum aus der ersten Spalte extrahieren (Pflichtfeld) const date = parseDateFromDayString(dayString); if (!date) { console.log(` -> Datum konnte nicht geparst werden aus: "${dayString}"`); const rowNum = rows.indexOf(row) + 1; errors.push(`Zeile ${rowNum}: Konnte Datum nicht aus "${dayString}" extrahieren.\nVollständige Zeile:\nSpalte 1: "${dayString}"\nSpalte 2: "${worshipCell.substring(0, 200)}${worshipCell.length > 200 ? '...' : ''}"`); continue; } console.log(` -> Datum geparst: ${date}`); // Gottesdienste in der Vergangenheit nicht weiter verarbeiten/anzeigen const worshipDate = new Date(date); worshipDate.setHours(0, 0, 0, 0); if (worshipDate < today) { console.log(` -> Datum liegt in der Vergangenheit, Zeile wird übersprungen`); continue; } // Gottesdienst(e) aus der zweiten Spalte extrahieren // Hinweis: Vergangene Daten werden erst beim Speichern herausgefiltert, // damit sie im Dialog zur Bearbeitung angezeigt werden können // Suche nach allen Uhrzeiten im Format "XX.XX Uhr" oder "XX:XX Uhr" const timeMatches = [...worshipCell.matchAll(/(\d{1,2})[:.](\d{2})\s*Uhr/gi)]; if (timeMatches.length === 0) { // Keine Uhrzeit gefunden, versuche trotzdem zu parsen (Uhrzeit ist optional) const worship = parseWorshipFromCell(worshipCell, date, ''); if (worship) { importedWorships.push(worship); } else { // Falls Parsing komplett fehlschlägt, erstelle trotzdem einen minimalen Eintrag mit Datum const dateStr = date ? date.toISOString().split('T')[0] : 'unbekannt'; const minimalWorship = { date: date, dayName: '', time: null, title: 'Gottesdienst', organizer: '', sacristanService: '', collection: '', organPlaying: '', eventPlaceId: null }; importedWorships.push(minimalWorship); console.log(` -> Minimaler Gottesdienst erstellt (nur Datum): ${dateStr}`); } continue; } // Teile die Zelle an jeder Uhrzeit const worshipBlocks = []; for (let i = 0; i < timeMatches.length; i++) { const match = timeMatches[i]; const startIndex = match.index; const endIndex = i < timeMatches.length - 1 ? timeMatches[i + 1].index : worshipCell.length; const block = worshipCell.substring(startIndex, endIndex).trim(); if (block.length > 0) { worshipBlocks.push(block); } } // Jeden Gottesdienst-Block parsen console.log(` -> ${worshipBlocks.length} Gottesdienst-Blöcke gefunden`); for (const block of worshipBlocks) { console.log(` -> Parse Block: "${block.substring(0, 100)}..."`); const worship = parseWorshipFromCell(block, date, ''); if (worship) { console.log(` -> Gottesdienst erfolgreich geparst: ${worship.time || 'keine Uhrzeit'} - ${worship.title}`); importedWorships.push(worship); } else { // Falls Parsing fehlschlägt, erstelle trotzdem einen minimalen Eintrag mit Datum console.log(` -> Gottesdienst konnte nicht geparst werden, erstelle minimalen Eintrag`); const dateStr = date ? date.toISOString().split('T')[0] : 'unbekannt'; const minimalWorship = { date: date, dayName: '', time: null, title: 'Gottesdienst', organizer: '', sacristanService: '', collection: '', organPlaying: '', eventPlaceId: null }; importedWorships.push(minimalWorship); } } } console.log(`Geparste Gottesdienste: ${importedWorships.length}`); console.log(`Fehler beim Parsen: ${errors.length}`); // EventPlace- und Tag-Name-Zuordnung für alle geparsten Gottesdienste durchführen // Hinweis: Vergangene Daten werden erst beim Speichern herausgefiltert, // damit sie im Dialog zur Bearbeitung angezeigt werden können // Sammle alle Daten der geparsten Gottesdienste für Datenbankabfrage const datesToCheck = new Set(); for (const worshipData of importedWorships) { if (worshipData.date) { let dateStr; if (worshipData.date instanceof Date) { const year = worshipData.date.getFullYear(); const month = String(worshipData.date.getMonth() + 1).padStart(2, '0'); const day = String(worshipData.date.getDate()).padStart(2, '0'); dateStr = `${year}-${month}-${day}`; } else if (typeof worshipData.date === 'string') { dateStr = worshipData.date.split('T')[0]; } if (dateStr) { datesToCheck.add(dateStr); } } } // Lade alle bestehenden Gottesdienste für die relevanten Daten // Verwende DATE() Funktion, um nur das Datum zu vergleichen (ohne Zeit) // Lade auch EventPlace-Beziehung für Vergleich // WICHTIG: Lade alle Felder inklusive organPlaying für Vergleich let existingWorships = []; if (datesToCheck.size > 0) { existingWorships = await Worship.findAll({ where: { [Op.or]: Array.from(datesToCheck).map(dateStr => sequelize.where(sequelize.fn('DATE', sequelize.col('date')), dateStr) ) }, // Lade alle Felder (organPlaying ist standardmäßig enthalten) include: { model: EventPlace, as: 'eventPlace' } }); console.log(` -> ${existingWorships.length} bestehende Gottesdienste geladen für Vergleich`); } // Hilfsfunktion: Prüft ob ein Feld mehr Daten hat const hasMoreData = (newValue, oldValue) => { const newHasData = newValue && String(newValue).trim().length > 0; const oldHasData = oldValue && String(oldValue).trim().length > 0; return newHasData && !oldHasData; }; // Hilfsfunktion: Prüft ob ein Feld weniger Daten hat const hasLessData = (newValue, oldValue) => { const newHasData = newValue && String(newValue).trim().length > 0; const oldHasData = oldValue && String(oldValue).trim().length > 0; return !newHasData && oldHasData; }; // Hilfsfunktion: Prüft ob ein Eintrag relevante Änderungen hat const hasRelevantChanges = (newWorship, existingWorship) => { // Wenn kein bestehender Eintrag existiert, prüfe ob der neue Eintrag relevante Daten hat if (!existingWorship) { // Prüfe ob mindestens ein Feld außer Datum, Zeit und Ort gefüllt ist const hasRelevantData = newWorship.title || newWorship.organizer || newWorship.sacristanService || newWorship.collection || newWorship.organPlaying; return !!hasRelevantData; // Nur relevant wenn mindestens ein Feld gefüllt ist } let hasMoreOrChanged = false; let hasLess = false; // Vergleiche alle relevanten Felder const fieldsToCompare = ['time', 'title', 'organizer', 'sacristanService', 'collection', 'organPlaying', 'eventPlaceId']; // Hilfsfunktion zum Normalisieren von Uhrzeiten (ignoriere Sekunden) const normalizeTimeForComparison = (timeStr) => { if (!timeStr) return ''; return String(timeStr).substring(0, 5); // Nur HH:MM }; for (const field of fieldsToCompare) { let newValue = newWorship[field]; let oldValue = existingWorship[field]; // Spezielle Behandlung für Uhrzeit: ignoriere Sekunden beim Vergleich if (field === 'time') { newValue = normalizeTimeForComparison(newValue); oldValue = normalizeTimeForComparison(oldValue); } // Normalisiere Werte für Vergleich // Behandle null, undefined und leere Strings als gleich const normalizeValue = (val) => { if (val === null || val === undefined) return ''; const str = String(val).trim(); return str.length === 0 ? '' : str; }; const newValueStr = normalizeValue(newValue); const oldValueStr = normalizeValue(oldValue); // Wenn beide Werte identisch sind (oder beide leer), überspringe if (newValueStr === oldValueStr) { continue; } // Prüfe ob neuer Wert mehr Daten hat (war leer, jetzt gefüllt) if (newValueStr && !oldValueStr) { hasMoreOrChanged = true; console.log(` -> Feld "${field}": Mehr Daten (neu: "${newValueStr}", alt: leer)`); } // Prüfe ob neuer Wert weniger Daten hat (war gefüllt, jetzt leer) if (!newValueStr && oldValueStr) { hasLess = true; console.log(` -> Feld "${field}": Weniger Daten (neu: leer, alt: "${oldValueStr}")`); } // Prüfe ob Werte unterschiedlich sind (beide gefüllt) if (newValueStr && oldValueStr && newValueStr !== oldValueStr) { hasMoreOrChanged = true; console.log(` -> Feld "${field}": Geändert (neu: "${newValueStr}", alt: "${oldValueStr}")`); } } // Nur relevant wenn mehr/andere Daten vorhanden sind UND nicht weniger Daten const isRelevant = hasMoreOrChanged && !hasLess; console.log(` -> hasRelevantChanges: hasMoreOrChanged=${hasMoreOrChanged}, hasLess=${hasLess}, result=${isRelevant}`); return isRelevant; }; // Hilfsfunktion: Finde passenden bestehenden Eintrag const findExistingWorship = (worshipData) => { if (!worshipData.date) return null; let dateStr; if (worshipData.date instanceof Date) { const year = worshipData.date.getFullYear(); const month = String(worshipData.date.getMonth() + 1).padStart(2, '0'); const day = String(worshipData.date.getDate()).padStart(2, '0'); dateStr = `${year}-${month}-${day}`; } else if (typeof worshipData.date === 'string') { dateStr = worshipData.date.split('T')[0]; } else { return null; } // Suche nach passendem Eintrag (Datum, Uhrzeit, Ort) return existingWorships.find(existing => { // Konvertiere bestehendes Datum zu YYYY-MM-DD let existingDateStr; if (existing.date instanceof Date) { const year = existing.date.getFullYear(); const month = String(existing.date.getMonth() + 1).padStart(2, '0'); const day = String(existing.date.getDate()).padStart(2, '0'); existingDateStr = `${year}-${month}-${day}`; } else if (typeof existing.date === 'string') { existingDateStr = existing.date.split('T')[0]; } else { return false; } if (existingDateStr !== dateStr) return false; // Vergleiche Uhrzeit (normalisiere beide, ignoriere Sekunden) const normalizeTime = (timeStr) => { if (!timeStr) return null; const str = String(timeStr); // Extrahiere nur HH:MM (erste 5 Zeichen) return str.substring(0, 5); }; const newTime = normalizeTime(worshipData.time); const existingTime = normalizeTime(existing.time); if (newTime !== existingTime) return false; // Vergleiche Ort const newPlaceId = worshipData.eventPlaceId || null; const existingPlaceId = existing.eventPlaceId || null; if (newPlaceId !== existingPlaceId) return false; return true; }); }; for (const worshipData of importedWorships) { // EventPlace zuordnen, falls ein Ort explizit extrahiert wurde if (worshipData.extractedLocation) { const eventPlace = await EventPlace.findOne({ where: { name: worshipData.extractedLocation } }); if (eventPlace) { worshipData.eventPlaceId = eventPlace.id; } else { // Falls nicht gefunden, versuche auch ohne "Kirche " Präfix const locationWithoutKirche = worshipData.extractedLocation.replace(/^Kirche\s+/i, '').trim(); // Zuerst versuchen wir, einen Ort zu finden, der sowohl den Ortsnamen // als auch "Kirche" im Namen enthält (z.B. "Ev. Kirche Nieder-Erlenbach"). // Damit wird bei mehreren Treffern (Kirche / Gemeindehaus) bevorzugt die Kirche gewählt. let eventPlaceAlt = await EventPlace.findOne({ where: { [Op.and]: [ { name: { [Op.like]: `%${locationWithoutKirche}%` } }, { name: { [Op.like]: `%Kirche%` } } ] } }); // Falls keine passende Kirche gefunden wurde, auf die bisherige Logik zurückfallen if (!eventPlaceAlt) { eventPlaceAlt = await EventPlace.findOne({ where: { name: { [Op.like]: `%${locationWithoutKirche}%` } } }); } if (eventPlaceAlt) { worshipData.eventPlaceId = eventPlaceAlt.id; } } // Entferne extractedLocation, da es nicht in der Datenbank gespeichert wird delete worshipData.extractedLocation; } // Fallback: Wenn noch kein Ort gesetzt ist, versuche den Titel als Ortsnamen zu interpretieren if (!worshipData.eventPlaceId && worshipData.title && typeof worshipData.title === 'string') { const titleTrimmed = worshipData.title.trim(); // "Einfache" Titel als mögliche Ortsnamen zulassen: // - Beginn mit Großbuchstaben // - nur Buchstaben, Bindestriche und Leerzeichen const isSimpleLocationLike = /^[A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]*(?:\s+[A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]*)*$/.test(titleTrimmed); if (isSimpleLocationLike) { const normalizedLocation = titleTrimmed .split(/\s*-\s*/) .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join('-'); // 1. Versuche: exakter Name "Kirche " let eventPlaceFromTitle = await EventPlace.findOne({ where: { name: `Kirche ${normalizedLocation}` } }); // 2. Versuche: Name enthält Ortsnamen und "Kirche" if (!eventPlaceFromTitle) { eventPlaceFromTitle = await EventPlace.findOne({ where: { [Op.and]: [ { name: { [Op.like]: `%${normalizedLocation}%` } }, { name: { [Op.like]: `%Kirche%` } } ] } }); } // 3. Versuche: nur der Ortsname (z.B. wenn EventPlace "Nieder-Eschbach" heißt) if (!eventPlaceFromTitle) { eventPlaceFromTitle = await EventPlace.findOne({ where: { name: { [Op.like]: `%${normalizedLocation}%` } } }); } if (eventPlaceFromTitle) { worshipData.eventPlaceId = eventPlaceFromTitle.id; } } } // Tag-Name aus der Datenbank basierend auf dem Datum holen if (worshipData.date) { let dateStr; if (worshipData.date instanceof Date) { // Konvertiere Date zu YYYY-MM-DD Format const year = worshipData.date.getFullYear(); const month = String(worshipData.date.getMonth() + 1).padStart(2, '0'); const day = String(worshipData.date.getDate()).padStart(2, '0'); dateStr = `${year}-${month}-${day}`; } else if (typeof worshipData.date === 'string') { // Falls bereits String, verwende direkt (sollte YYYY-MM-DD sein) dateStr = worshipData.date.split('T')[0]; } else { dateStr = null; } if (dateStr) { console.log(` -> Suche liturgischen Tag für Datum: ${dateStr}`); const liturgicalDay = await LiturgicalDay.findOne({ where: { date: dateStr } }); if (liturgicalDay) { worshipData.dayName = liturgicalDay.dayName; console.log(` -> Tag-Name gefunden: "${liturgicalDay.dayName}"`); } else { // Falls kein liturgischer Tag gefunden, leer lassen worshipData.dayName = ''; console.log(` -> Kein liturgischer Tag für Datum ${dateStr} gefunden`); } } else { worshipData.dayName = ''; } } } // Filtere Gottesdienste: Nur die mit relevanten Änderungen behalten // Dies geschieht bereits im Backend, bevor Daten an das Frontend gesendet werden const worshipsWithChanges = []; let skippedCount = 0; for (const worshipData of importedWorships) { const existingWorship = findExistingWorship(worshipData); // Konvertiere Datum für Vergleich let dateStrForCompare = ''; if (worshipData.date instanceof Date) { const year = worshipData.date.getFullYear(); const month = String(worshipData.date.getMonth() + 1).padStart(2, '0'); const day = String(worshipData.date.getDate()).padStart(2, '0'); dateStrForCompare = `${year}-${month}-${day}`; } else if (typeof worshipData.date === 'string') { dateStrForCompare = worshipData.date.split('T')[0]; } console.log(` -> Prüfe Gottesdienst: Datum=${dateStrForCompare}, Zeit=${worshipData.time}, Ort=${worshipData.eventPlaceId}`); console.log(` -> organPlaying im neuen: "${worshipData.organPlaying || '(leer)'}" (Typ: ${typeof worshipData.organPlaying}, Wert: ${JSON.stringify(worshipData.organPlaying)})`); if (existingWorship) { console.log(` -> Bestehender Eintrag gefunden: ID=${existingWorship.id}`); console.log(` -> organPlaying im bestehenden: "${existingWorship.organPlaying || '(leer)'}" (Typ: ${typeof existingWorship.organPlaying}, Wert: ${JSON.stringify(existingWorship.organPlaying)})`); } else { console.log(` -> Kein bestehender Eintrag gefunden (neuer Eintrag)`); // Debug: Zeige alle bestehenden Einträge für dieses Datum const existingForDate = existingWorships.filter(ex => { let exDateStr = ''; if (ex.date instanceof Date) { const year = ex.date.getFullYear(); const month = String(ex.date.getMonth() + 1).padStart(2, '0'); const day = String(ex.date.getDate()).padStart(2, '0'); exDateStr = `${year}-${month}-${day}`; } else if (typeof ex.date === 'string') { exDateStr = ex.date.split('T')[0]; } return exDateStr === dateStrForCompare; }); console.log(` -> Bestehende Einträge für dieses Datum: ${existingForDate.length}`); existingForDate.forEach(ex => { const exTime = ex.time ? String(ex.time).substring(0, 5) : 'keine'; const exPlace = ex.eventPlaceId || 'kein Ort'; console.log(` -> ID=${ex.id}, Zeit=${exTime}, Ort=${exPlace}`); }); } // Prüfe ob relevante Änderungen vorhanden sind if (hasRelevantChanges(worshipData, existingWorship)) { // Markiere als Update, falls bestehender Eintrag existiert if (existingWorship) { worshipData._isUpdate = true; worshipData._isNew = false; worshipData._existingId = existingWorship.id; // Speichere alte Werte für Vergleich const fieldsToCompare = ['time', 'title', 'organizer', 'sacristanService', 'collection', 'organPlaying', 'eventPlaceId']; worshipData._oldValues = {}; worshipData._changedFields = []; // Verwende die gleiche Normalisierungslogik wie in hasRelevantChanges const normalizeValueForComparison = (val) => { if (val === null || val === undefined) return ''; const str = String(val).trim(); return str.length === 0 ? '' : str; }; const normalizeTimeForComparison = (timeStr) => { if (!timeStr) return ''; return String(timeStr).substring(0, 5); // Nur HH:MM }; for (const field of fieldsToCompare) { let newValue = worshipData[field]; let oldValue = existingWorship[field]; // Spezielle Behandlung für Uhrzeit: ignoriere Sekunden beim Vergleich if (field === 'time') { newValue = normalizeTimeForComparison(newValue); oldValue = normalizeTimeForComparison(oldValue); } // Normalisiere Werte für Vergleich (behandle null, undefined und leere Strings als gleich) const newValueStr = normalizeValueForComparison(newValue); const oldValueStr = normalizeValueForComparison(oldValue); if (newValueStr !== oldValueStr) { // Für Anzeige: verwende formatierte Version if (field === 'time' && oldValue) { // Zeige alte Uhrzeit ohne Sekunden const oldTimeStr = String(oldValue); worshipData._oldValues[field] = oldTimeStr.substring(0, 5) || '(leer)'; } else { // Zeige alten Wert, oder "(leer)" wenn leer/null const displayValue = oldValue ? String(oldValue).trim() : ''; worshipData._oldValues[field] = displayValue || '(leer)'; } worshipData._changedFields.push(field); console.log(` -> Feld "${field}" als geändert markiert: neu="${newValueStr}", alt="${oldValueStr}"`); } } // Speichere auch alten EventPlace-Namen, falls vorhanden if (existingWorship.eventPlace && existingWorship.eventPlace.name) { worshipData._oldValues.eventPlaceName = existingWorship.eventPlace.name; } console.log(` -> Relevante Änderungen gefunden für bestehenden Eintrag ID ${existingWorship.id}: ${worshipData._changedFields.join(', ')}`); } else { // Komplett neuer Eintrag (Datum, Uhrzeit, Ort existieren noch nicht) worshipData._isUpdate = false; worshipData._isNew = true; worshipData._oldValues = {}; worshipData._changedFields = []; console.log(` -> Neuer Eintrag wird hinzugefügt (Datum, Uhrzeit, Ort existieren noch nicht)`); } worshipsWithChanges.push(worshipData); } else { skippedCount++; if (existingWorship) { console.log(` -> Eintrag übersprungen (weniger oder keine relevanten Änderungen) für ID ${existingWorship.id}`); } else { console.log(` -> Eintrag übersprungen (keine relevanten Daten)`); } } } console.log(`Gottesdienste mit relevanten Änderungen: ${worshipsWithChanges.length}`); console.log(`Übersprungene Gottesdienste: ${skippedCount}`); // Geparste Daten zurückgeben (ohne zu speichern) // Hinweis: Nur Gottesdienste mit relevanten Änderungen werden zurückgegeben // Konvertiere Date-Objekte zu YYYY-MM-DD Strings für das Frontend const worshipsForFrontend = worshipsWithChanges.map(w => { const worshipCopy = { ...w }; // Konvertiere Datum zu YYYY-MM-DD Format if (worshipCopy.date instanceof Date) { const year = worshipCopy.date.getFullYear(); const month = String(worshipCopy.date.getMonth() + 1).padStart(2, '0'); const day = String(worshipCopy.date.getDate()).padStart(2, '0'); worshipCopy.date = `${year}-${month}-${day}`; } else if (typeof worshipCopy.date === 'string') { // Falls bereits String, stelle sicher, dass es YYYY-MM-DD ist worshipCopy.date = worshipCopy.date.split('T')[0]; } return worshipCopy; }); res.status(200).json({ message: `Datei erfolgreich geparst. ${worshipsWithChanges.length} Gottesdienste mit relevanten Änderungen gefunden (${skippedCount} übersprungen).`, worships: worshipsForFrontend, errors: errors.length > 0 ? errors : undefined }); } catch (error) { console.error('Fehler beim Importieren der Gottesdienste:', error); res.status(500).json({ message: 'Fehler beim Importieren der Gottesdienste', error: error.message }); } }; // Import-Funktion für Gottesdienste aus dem neuen NBR-CSV Format (2026+) exports.importWorshipsNbrCsv = async (req, res) => { try { if (!req.file) { return res.status(400).json({ message: 'Keine Datei hochgeladen.' }); } const fileName = req.file.originalname.toLowerCase(); if (!fileName.endsWith('.csv')) { return res.status(400).json({ message: 'Nur .csv Dateien sind erlaubt.' }); } const csvText = req.file.buffer.toString('utf8'); const records = parseCsv(csvText, { relax_quotes: true, relax_column_count: true, skip_empty_lines: false, }); if (!Array.isArray(records) || records.length < 3) { return res.status(400).json({ message: 'CSV hat zu wenig Zeilen.' }); } const header = records[0] || []; const datumCol = 1; const groups = []; for (let idx = 2; idx < header.length; idx += 3) { const placeHeader = header[idx]; if (!normalizeText(placeHeader)) continue; groups.push({ idx, placeHeader, musicIdx: idx + 1, serviceIdx: idx + 2, }); } const eventPlaces = await EventPlace.findAll(); const leaders = await WorshipLeader.findAll(); const { normalizedToName } = buildLeaderMaps(leaders); // existing worships for change detection const existingWorships = await Worship.findAll({ where: { date: { [Op.gte]: literal('DATE_SUB(CURDATE(), INTERVAL 1 DAY)'), }, }, }); const today = new Date(); today.setUTCHours(0, 0, 0, 0); const imported = []; const errors = []; const findExisting = (dateUtc, time, eventPlaceId) => { const y = dateUtc.getUTCFullYear(); const m = String(dateUtc.getUTCMonth() + 1).padStart(2, '0'); const d = String(dateUtc.getUTCDate()).padStart(2, '0'); const dateKey = `${y}-${m}-${d}`; const timeKey = time ? String(time).substring(0, 5) : ''; return existingWorships.find((w) => { const wDate = w.date instanceof Date ? w.date : new Date(w.date); const wy = wDate.getUTCFullYear(); const wm = String(wDate.getUTCMonth() + 1).padStart(2, '0'); const wd = String(wDate.getUTCDate()).padStart(2, '0'); const wKey = `${wy}-${wm}-${wd}`; const wTime = w.time ? String(w.time).substring(0, 5) : ''; return wKey === dateKey && wTime === timeKey && String(w.eventPlaceId || '') === String(eventPlaceId || ''); }); }; const hasChanges = (newW, existing) => { if (!existing) return true; const fields = ['title', 'organizer', 'sacristanService', 'collection', 'organPlaying', 'eventPlaceId']; for (const field of fields) { const a = normalizeText(newW[field]); const b = normalizeText(existing[field]); if (a !== b) return true; } return false; }; for (let r = 1; r < records.length; r++) { const row = records[r] || []; const dateUtc = parseGermanDateString(row[datumCol]); if (!dateUtc) continue; const baseDateUtc = dateUtc; const dayName = normalizeText(row[0]).replace(/\s*\(\s*/g, ' (').trim(); // Skip past days for preview (same behavior as docx import) const compare = new Date(baseDateUtc.getTime()); compare.setUTCHours(0, 0, 0, 0); if (compare < today) continue; for (const group of groups) { const placeHeader = group.placeHeader; const eventPlaceId = resolveEventPlaceIdFromHeader(eventPlaces, placeHeader); const worshipCell = row[group.idx]; const music = row[group.musicIdx]; const service = row[group.serviceIdx]; const segments = splitNbrCellToSegments(worshipCell); if (segments.length === 0) continue; for (const seg of segments) { const parsed = parseNbrSegment(seg, baseDateUtc, normalizedToName); if (!parsed || !parsed.time) { continue; } const worshipData = { date: parsed.dateUtc, dayName, time: parsed.time, title: parsed.title, // "Gottesdienst haltend" ist bei uns der "Gestalter" (organizer). organizer: parsed.officiant || '', collection: '', sacristanService: normalizeText(service), organPlaying: normalizeText(music), eventPlaceId, }; const existing = findExisting(worshipData.date, worshipData.time, worshipData.eventPlaceId); if (!hasChanges(worshipData, existing)) { continue; } if (existing) { worshipData._isUpdate = true; worshipData._existingId = existing.id; worshipData._oldValues = { title: existing.title, organizer: existing.organizer, sacristanService: existing.sacristanService, collection: existing.collection, organPlaying: existing.organPlaying, eventPlaceId: existing.eventPlaceId, }; worshipData._changedFields = Object.keys(worshipData._oldValues).filter((f) => normalizeText(worshipData[f]) !== normalizeText(existing[f])); } else { worshipData._isNew = true; } imported.push(worshipData); } } } const worshipsForFrontend = imported.map((w) => { const copy = { ...w }; if (copy.date instanceof Date) { const year = copy.date.getUTCFullYear(); const month = String(copy.date.getUTCMonth() + 1).padStart(2, '0'); const day = String(copy.date.getUTCDate()).padStart(2, '0'); copy.date = `${year}-${month}-${day}`; } if (copy.time && typeof copy.time === 'string' && copy.time.length > 5) { copy.time = copy.time.substring(0, 5); } return copy; }); res.status(200).json({ message: `CSV geparst. ${worshipsForFrontend.length} Einträge mit Änderungen gefunden.`, worships: worshipsForFrontend, errors: errors.length ? errors : undefined, }); } catch (error) { console.error('Fehler beim Importieren (NBR CSV):', error); res.status(500).json({ message: 'Fehler beim Importieren der CSV', error: error.message }); } }; // Funktion zum Speichern der bearbeiteten Gottesdienste exports.saveImportedWorships = async (req, res) => { try { const { worships } = req.body; if (!worships || !Array.isArray(worships)) { return res.status(400).json({ message: 'Keine Gottesdienste zum Speichern übergeben.' }); } let savedCount = 0; let updatedCount = 0; const errors = []; const today = new Date(); today.setHours(0, 0, 0, 0); for (const worshipData of worships) { try { // Prüfen ob Datum in der Vergangenheit liegt const worshipDate = new Date(worshipData.date); worshipDate.setHours(0, 0, 0, 0); if (worshipDate < today) { continue; // Überspringe vergangene Daten } // Freigabe-Status aus Import-Dialog übernehmen (Checkbox in der UI). // Fallback: wenn kein Wert gesetzt ist, bleibt es false. worshipData.approved = !!worshipData.approved; // Prüfen ob bereits ein Eintrag für dieses Datum und diese Uhrzeit existiert const whereClause = { date: { [Op.eq]: sequelize.fn('DATE', worshipData.date) }, time: worshipData.time }; // Wenn eventPlaceId gesetzt ist, auch danach suchen if (worshipData.eventPlaceId) { whereClause.eventPlaceId = worshipData.eventPlaceId; } else { // Wenn kein eventPlaceId, suche nach Einträgen ohne eventPlaceId whereClause.eventPlaceId = { [Op.is]: null }; } const existingWorship = await Worship.findOne({ where: whereClause }); if (existingWorship) { // Update bestehenden Eintrag await existingWorship.update(worshipData); updatedCount++; } else { // Neuen Eintrag erstellen await Worship.create(worshipData); savedCount++; } } catch (error) { console.error('Fehler beim Speichern eines Gottesdienstes:', error); errors.push(`Fehler beim Speichern: ${worshipData.date} ${worshipData.time} - ${worshipData.title || 'Unbekannt'}`); } } const totalProcessed = savedCount + updatedCount; res.status(200).json({ message: `Import abgeschlossen. ${savedCount} neue Gottesdienste erstellt, ${updatedCount} aktualisiert.`, imported: savedCount, updated: updatedCount, total: totalProcessed, skipped: worships.length - totalProcessed, errors: errors }); } catch (error) { console.error('Fehler beim Speichern der importierten Gottesdienste:', error); res.status(500).json({ message: 'Fehler beim Speichern der Gottesdienste', error: error.message }); } }; function normalizePdfLines(rawText) { return rawText .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .filter((line) => line.length > 0) .filter((line) => !/^--\s*\d+\s+of\s+\d+\s*--$/i.test(line)); } function findFirstIndex(lines, predicate, from = 0) { for (let i = from; i < lines.length; i++) { if (predicate(lines[i])) return i; } return -1; } function getSection(lines, startPredicate, endPredicates = []) { const start = findFirstIndex(lines, startPredicate); if (start < 0) return []; let end = lines.length; for (const p of endPredicates) { const idx = findFirstIndex(lines, p, start + 1); if (idx >= 0) end = Math.min(end, idx); } return lines.slice(start, end); } function normalizeText(input) { return String(input || '') .toLowerCase() .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ß/g, 'ss') .replace(/\s+/g, ' ') .trim(); } function isHeading(line, heading) { const normalizedLine = normalizeText(line).replace(/[:\-–]\s*$/g, ''); const normalizedHeading = normalizeText(heading).replace(/[:\-–]\s*$/g, ''); return ( normalizedLine === normalizedHeading || normalizedLine.startsWith(`${normalizedHeading} `) || normalizedHeading.startsWith(`${normalizedLine} `) ); } function getSectionByHeading(lines, startHeading, endHeadings = []) { const start = findFirstIndex(lines, (l) => isHeading(l, startHeading)); if (start < 0) return []; let end = lines.length; for (const endHeading of endHeadings) { const idx = findFirstIndex(lines, (l) => isHeading(l, endHeading), start + 1); if (idx >= 0) end = Math.min(end, idx); } return lines.slice(start, end); } function extractEventCandidates(lines) { const seen = new Set(); return lines.filter((line) => { const normalized = line.toLowerCase(); const hasDate = /\b\d{1,2}\.\d{1,2}\.(\d{4})?\b/.test(line) || /\b\d{1,2}\.\d{2}\s*uhr\b/i.test(line) || /\b\d{1,2}:\d{2}\s*uhr\b/i.test(line); const isDuplicate = seen.has(normalized); if (!isDuplicate && hasDate) { seen.add(normalized); return true; } return false; }); } function looksLikeHeading(line) { return /^(gottesdienste|regelmäßige termine|männer und frauen|kinder und jugend|senioren|besondere gottesdienste|und veranstaltungen)$/i.test(line.trim()); } function hasDateOrTime(line) { return ( /\b\d{1,2}\.\d{1,2}\.(\d{2,4})?\b/.test(line) || /\b\d{1,2}\.\d{1,2}\.?,\s*\d{1,2}\.\d{1,2}\.(\d{2,4})?\b/.test(line) || /\b\d{1,2}[:.]\d{2}\s*uhr\b/i.test(line) || /\b\d{1,2}\.\d{2}\s*-\s*\d{1,2}\.\d{2}\s*uhr\b/i.test(line) ); } function buildDetailedItems(lines) { const result = []; const seen = new Set(); const isSectionLabel = (line) => /^(gottesdienste|veranstaltungen)\s*:?\s*$/i.test(String(line || '').trim()); const youthAnchorPattern = /\b(kinderkirche|kigosabo|kindergottesdienst|jungschar|heliand-?pfadfinder(?:innen)?|pfadfinder(?:innen)?|konfirmationsunterricht|konfirmanden|vorkonfirmandenkurs)\b/i; const splitForYouthAnchors = (line) => { const compact = String(line || '').replace(/\s+/g, ' ').trim(); if (!compact) return []; const withCuts = compact .replace(/\s+(?=(kinderkirche|kigosabo|kindergottesdienst|jungschar|heliand-?pfadfinder(?:innen)?|pfadfinder(?:innen)?|konfirmationsunterricht|konfirmanden|vorkonfirmandenkurs)\b)/gi, ' || ') .replace(/\s+(?=(montag:|dienstag:|mittwoch:|donnerstag:|freitag:|samstag:|sonntag:))/gi, ' || '); return withCuts.split('||').map((s) => s.trim()).filter(Boolean); }; const expandedLines = lines.flatMap(splitForYouthAnchors); const isHardSectionBoundary = (line) => { const n = normalizeText(line); if (!n) return false; return ( n.startsWith('besondere gottesdienste und veranstaltungen') || n.includes('nieder-erlenbach und harheim') || n.startsWith('wunderbarer norden') || n.startsWith('leben vor dem tod') || /^seite?\s*\d+$/i.test(String(line || '').trim()) || /^\d{1,3}$/.test(String(line || '').trim()) ); }; const isEntryStart = (line) => { if (!line || looksLikeHeading(line) || isSectionLabel(line) || isHardSectionBoundary(line)) return false; if (/^(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag):/i.test(String(line).trim())) return true; if (youthAnchorPattern.test(line)) return true; const hasScheduleSignal = hasDateOrTime(line) || /\b\d{1,2}[:.]\d{2}\s*(?:-\s*\d{1,2}[:.]\d{2}\s*)?uhr\b/i.test(line) || /\b(von|um)\s+\d{1,2}[:.]\d{2}\b/i.test(line) || /\b(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)s?\b/i.test(line) || /\btermine[:\s]/i.test(line); if (youthAnchorPattern.test(line) && hasScheduleSignal) return true; // Klassische Startzeilen in den PDFs: // "So., 08.02. 11.00 Uhr ..." oder "Mi., 18.02. 19.00 - 20.30 Uhr ..." if (/^(so|mo|di|mi|do|fr|sa)\.,?\s+\d{1,2}\.\d{1,2}\./i.test(line)) return true; // Fallback: enthalt Datum + Uhrzeit in derselben Zeile. return /\b\d{1,2}\.\d{1,2}\./.test(line) && /\b\d{1,2}[:.]\d{2}\s*(?:-\s*\d{1,2}[:.]\d{2}\s*)?uhr\b/i.test(line); }; const hasYouthScheduleSignal = (line) => hasDateOrTime(line) || /\b\d{1,2}[:.]\d{2}\s*(?:-\s*\d{1,2}[:.]\d{2}\s*)?uhr\b/i.test(String(line || '')) || /\b(von|um)\s+\d{1,2}[:.]\d{2}\b/i.test(String(line || '')) || /\b(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b/i.test(String(line || '')) || /\btermine[:\s]/i.test(String(line || '')); for (let i = 0; i < expandedLines.length; i++) { const current = expandedLines[i]; if (!isEntryStart(current)) continue; const parts = [current]; for (let j = i + 1; j < expandedLines.length; j++) { const next = expandedLines[j]; if (!next) break; if (looksLikeHeading(next) || isSectionLabel(next) || isHardSectionBoundary(next)) break; if (isEntryStart(next)) { const currentIsYouthAnchor = youthAnchorPattern.test(current); const currentHasSchedule = hasYouthScheduleSignal(current); const nextIsStandaloneScheduleLine = !youthAnchorPattern.test(next); if (currentIsYouthAnchor && !currentHasSchedule && nextIsStandaloneScheduleLine) { parts.push(next); i = j; continue; } break; } if (isNoiseLine(next)) break; parts.push(next); i = j; // konsumierte Zeilen überspringen } const text = parts .join(' ') .replace(/\s*-\s+(?=[A-Za-zÄÖÜäöüß])/g, '') // harte Zeilentrennung "Gemein- desaal" heilen .replace(/\s+\|/g, ' |') .replace(/\s{2,}/g, ' ') .trim(); // Falls eine neue Abschnittsüberschrift in derselben Zeile klebt, // den Eintrag dort hart abschneiden. const textCutAtInlineBoundary = text .split(/\s+Besondere Gottesdienste und Veranstaltungen\b/i)[0] .split(/\s+Wunderbarer Norden\b/i)[0] .split(/\s+Leben vor dem Tod\b/i)[0] .trim(); const key = textCutAtInlineBoundary.toLowerCase(); if (!seen.has(key) && textCutAtInlineBoundary.length > 0) { seen.add(key); result.push(textCutAtInlineBoundary); } } return result; } function isNoiseLine(line) { const n = normalizeText(line); return ( n.includes('impressum') || n.includes('redaktionsschluss') || n.includes('visdp') || n.includes('buerozeiten') || n.includes('@:') || n.includes('@t-online.de') || n.includes('datenschutzerklaerung') || n.includes('logout') ); } function filterNoise(lines) { return lines.filter((line) => !isNoiseLine(line)); } function extractNamedBlock(lines, pattern, maxLookahead = 3, maxParts = 3) { const blocks = []; for (let i = 0; i < lines.length; i++) { if (!pattern.test(lines[i])) continue; const parts = [lines[i]]; for (let j = i + 1; j < Math.min(lines.length, i + 1 + maxLookahead); j++) { const candidate = lines[j]; if (looksLikeHeading(candidate)) break; if (isNoiseLine(candidate)) break; if (hasDateOrTime(candidate) || /\bum\s+\d{1,2}[:.]\d{2}\s*uhr\b/i.test(candidate)) { parts.push(candidate); } if (parts.length >= maxParts) break; } blocks.push(parts.join(' | ')); } return [...new Set(blocks)]; } function extractLinesByKeyword(lines, pattern) { return lines.filter((line) => pattern.test(line)); } function extractRegularTermineDetails(lines) { const anchors = [ /kinderkirche/i, /kigosabo/i, /jungschar/i, /konfirmationsunterricht/i, /konfirmanden\s*[„"]/i, /was geht abend/i, /vorkonfirmandenkurs/i, /pfadfinder/i, /miriamtreff/i, /m[aä]nnerpalaver/i, /frauenfr[üu]hst[üu]ck/i, /kinder- und jugendb[üu]cherei/i, /wunderkiste/i, /seniorenclub/i, /seniorencaf[eé]/i, ]; const splitRegularLineIntoSegments = (line) => { const compact = String(line || '').replace(/\s+/g, ' ').trim(); if (!compact) return []; const withAnchorCuts = compact .replace(/\s+(?=(kinderkirche|kigosabo|kindergottesdienst|jungschar|heliand-?pfadfinder(?:innen)?|pfadfinder(?:innen)?|konfirmationsunterricht|konfirmanden\s*[„"]|vorkonfirmandenkurs|m[aä]nnerpalaver|miriamtreff|frauenfr[üu]hst[üu]ck|wunderkiste)\b)/gi, ' || ') .replace(/\s+(?=(montag:|dienstag:|mittwoch:|donnerstag:|freitag:|samstag:|sonntag:))/gi, ' || '); return withAnchorCuts .split('||') .map((s) => s.trim()) .filter(Boolean); }; const expandedLines = lines.flatMap(splitRegularLineIntoSegments); const details = []; const seen = new Set(); const isAnchorLine = (line) => anchors.some((r) => r.test(line)); const isSubHeadingLike = (line) => { const t = normalizeText(line); return ( /^kinder und jugendliche$/.test(t) || /^kinder und jugend$/.test(t) || /^maenner und frauen$/.test(t) || /^musik$/.test(t) || /^senioren$/.test(t) ); }; for (let i = 0; i < expandedLines.length; i++) { const line = expandedLines[i]; if (!isAnchorLine(line)) continue; if (isNoiseLine(line)) continue; if (/start des neuen konfirmanden-jahrganges/i.test(line)) continue; if (/konfirmanden\s*\/\s*geburtstagsgr[üu][ßs]e/i.test(line)) continue; if (/jahrgang der miriamgemeinde/i.test(line)) continue; const parts = [line]; let hasScheduleSignal = hasDateOrTime(line) || /termine[:\s]/i.test(line); for (let j = i + 1; j < Math.min(expandedLines.length, i + 8); j++) { const next = expandedLines[j]; if (looksLikeHeading(next) || isSubHeadingLike(next) || isNoiseLine(next)) break; // Sobald ein neuer Anker startet, endet der aktuelle Block. if (isAnchorLine(next)) break; if (hasDateOrTime(next) || /termine[:\s]/i.test(next) || /\bmontag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag\b/i.test(next)) { parts.push(next); hasScheduleSignal = true; } else { break; } } if (!hasScheduleSignal) continue; let text = parts.join(' | '); // Manche PDFs liefern Miriamtreff + Männerpalaver in einer Zeile. // Für die Kategorie "Regelmäßige Termine" trennen wir das sauber. if (/^miriamtreff:/i.test(text) && /\|\s*m[aä]nnerpalaver/i.test(text)) { text = text.split(/\|\s*m[aä]nnerpalaver/i)[0].trim(); } const key = text.toLowerCase(); if (!seen.has(key)) { seen.add(key); details.push(text); } } return details; } function isDateHeaderLine(line) { return /\b\d{1,2}\.\d{1,2}\.(\d{2,4})?\b/.test(line); } function isLikelyDayNameLine(line) { if (!line) return false; if (hasDateOrTime(line)) return false; if (looksLikeHeading(line)) return false; const t = normalizeText(line); return ( /advent|trinitatis|epiphanias|ostern|pfingsten|sonntag|montag|dienstag|mittwoch|donnerstag|freitag|samstag/.test(t) && t.length < 80 ); } function splitWorshipLinesByTime(lines) { const entries = []; let current = null; const startsWithTime = (line) => /^\d{1,2}[:.]\d{2}\s*uhr\b/i.test(line); const isNeighborInvitationLine = (line) => /einladung zum gottesdienst im nachbarschaftsraum/i.test(line); const isSelfInformationLine = (line) => /bitte informieren sie sich auch auf den internetseiten/i.test(line); let stickyNeighborInvitation = false; let stickySelfInformation = false; for (const line of lines) { if (!line || isNoiseLine(line) || looksLikeHeading(line)) continue; if (isNeighborInvitationLine(line)) { stickyNeighborInvitation = true; if (current && current.length) current.push('[[FLAG_NEIGHBOR_INVITATION]]'); continue; } if (isSelfInformationLine(line)) { stickySelfInformation = true; if (current && current.length) current.push('[[FLAG_SELF_INFORMATION]]'); continue; } if (startsWithTime(line) && current && current.length) { entries.push(current.join(' | ')); current = [line]; if (stickyNeighborInvitation) current.push('[[FLAG_NEIGHBOR_INVITATION]]'); if (stickySelfInformation) current.push('[[FLAG_SELF_INFORMATION]]'); continue; } if (startsWithTime(line) && (!current || current.length === 0)) { current = []; current.push(line); if (stickyNeighborInvitation) current.push('[[FLAG_NEIGHBOR_INVITATION]]'); if (stickySelfInformation) current.push('[[FLAG_SELF_INFORMATION]]'); continue; } // Zeilen ohne Uhrzeit vor dem ersten Gottesdienst werden nur als Kontext verstanden. // Sie dürfen keinen eigenen Gottesdienst-Eintrag erzeugen. if (!current || current.length === 0) { continue; } // Zeilen ohne Uhrzeit nach einer Zeit gehören zum laufenden Gottesdienst (z.B. "Audite Nova"). current.push(line); } if (current && current.length) entries.push(current.join(' | ')); return entries; } function extractWorshipBlocks(lines) { const blocks = []; let currentHeader = ''; let currentDayNameParts = []; let rightColumnLines = []; let startedWorshipContent = false; const flush = () => { if (!currentHeader || rightColumnLines.length === 0) return; const currentDayName = currentDayNameParts.join(' ').replace(/\s+/g, ' ').trim(); const header = currentDayName ? `${currentHeader} - ${currentDayName}` : currentHeader; const worshipEntries = splitWorshipLinesByTime(rightColumnLines); if (worshipEntries.length === 0) { const joined = rightColumnLines.join(' | ').trim(); if (joined) { blocks.push(`${header} | ${joined}`); } } else { worshipEntries.forEach((entry) => blocks.push(`${header} | ${entry}`)); } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line || isNoiseLine(line) || looksLikeHeading(line)) continue; if (isDateHeaderLine(line)) { flush(); currentHeader = line; currentDayNameParts = []; rightColumnLines = []; startedWorshipContent = false; continue; } if (currentHeader && !startedWorshipContent && isLikelyDayNameLine(line)) { currentDayNameParts.push(line); continue; } if (currentHeader) { if (/^\d{1,2}[:.]\d{2}\s*uhr\b/i.test(line)) { startedWorshipContent = true; } rightColumnLines.push(line); } } flush(); return [...new Set(blocks.map((b) => b.trim()).filter(Boolean))]; } function buildEventSignature(line) { const text = normalizeText(line); const anchorPatterns = [ /kinderkirche/, /kigosabo|kindergottesdienst/, /jungschar/, /konfirmationsunterricht/, /konfirmanden/, /vorkonfirmandenkurs/, /pfadfinder/, /miriamtreff/, /maennerpalaver/, /frauenfruehstueck/, /seniorenclub/, /seniorencafe|senioren-cafe/, ]; const anchor = (anchorPatterns.find((r) => r.test(text)) || /./).source; const dates = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\b/g)] .map((m) => `${String(m[1]).padStart(2, '0')}.${String(m[2]).padStart(2, '0')}`) .sort() .join(','); const range = text.match(/\b(\d{1,2})[:.](\d{2})\s*-\s*(\d{1,2})[:.](\d{2})\s*uhr\b/i); const single = text.match(/\b(\d{1,2})[:.](\d{2})\s*uhr\b/i); const startTime = range ? `${String(range[1]).padStart(2, '0')}:${range[2]}` : (single ? `${String(single[1]).padStart(2, '0')}:${single[2]}` : ''); const endTime = range ? `${String(range[3]).padStart(2, '0')}:${range[4]}` : ''; const openTermine = /termine\s*:\s*noch offen|noch offen/.test(text) ? 'open' : ''; const placePatterns = [ /kita sternenzelt/, /gemeindehaus bonames/, /gemeindehaus nieder-eschbach/, /gemeindehaus nieder-erlenbach/, /crutzenhof kalbach/, /bonames/, /kalbach/, /nieder-eschbach/, /nieder-erlenbach/, /harheim/, ]; const place = (placePatterns.find((r) => r.test(text)) || /./).source; return `${anchor}|${dates}|${startTime}|${endTime}|${openTermine}|${place}`; } function dedupeBySignature(lines) { const seen = new Set(); const result = []; for (const line of lines || []) { const key = buildEventSignature(line); if (seen.has(key)) continue; seen.add(key); result.push(line); } return result; } function removeCrossSectionDuplicates(primaryLines, secondaryLines) { const primaryKeys = new Set((primaryLines || []).map((line) => buildEventSignature(line))); return (secondaryLines || []).filter((line) => !primaryKeys.has(buildEventSignature(line))); } exports.importNewsletterPdf = async (req, res) => { try { if (!req.file) { return res.status(400).json({ message: 'Keine PDF-Datei hochgeladen.' }); } const fileName = req.file.originalname.toLowerCase(); if (!fileName.endsWith('.pdf')) { return res.status(400).json({ message: 'Bitte eine PDF-Datei hochladen.' }); } const parsed = await pdfParse(req.file.buffer); const lines = normalizePdfLines(parsed.text || ''); const gottesdiensteLines = getSection( lines, (l) => l.toLowerCase() === 'gottesdienste' || /^8\s+gottesdienste$/i.test(l), [ (l) => /besondere gottesdienste/i.test(l), ] ); const regelmaessigSection = getSectionByHeading( lines, 'Regelmäßige Termine', ['Neues von den Senioren', 'Kinder und Jugendliche'] ); const maennerFrauenSection = getSectionByHeading( lines, 'Männer und Frauen', ['Musik', 'Kinder und Jugendliche'] ); const seniorenKeywordLines = extractLinesByKeyword(lines, /seniorenclub|senioren-?caf[eé]/i); const regelmaessigLines = [...regelmaessigSection, ...maennerFrauenSection, ...seniorenKeywordLines]; const besondereLines = getSectionByHeading( lines, 'Besondere Gottesdienste', ['Regelmäßige Termine', 'Männer und Frauen', 'Kinder und Jugendliche', 'Neues von den Senioren'] ); const miriamtreffLines = extractLinesByKeyword(lines, /miriamtreff/i); const frauenfruehstueckLines = extractNamedBlock(lines, /frauenfrühstück|frauenfruehstueck/i, 8, 5); const kinderJugendLines = getSection( lines, (l) => /^kinder und jugendliche$/i.test(l), [ (l) => /^senioren$/i.test(l), ] ); const cleanedGottesdienste = filterNoise(gottesdiensteLines); const cleanedRegelmaessig = filterNoise(regelmaessigLines); const cleanedBesondere = filterNoise(besondereLines); const cleanedKinderJugend = filterNoise(kinderJugendLines); const regelmaessigDetails = dedupeBySignature(extractRegularTermineDetails(cleanedRegelmaessig)); const seniorenDetails = dedupeBySignature(extractRegularTermineDetails(filterNoise(seniorenKeywordLines))); const mergedRegelmaessigDetails = dedupeBySignature([...regelmaessigDetails, ...seniorenDetails]); const kinderUndJugendDetails = dedupeBySignature(buildDetailedItems(cleanedKinderJugend)); const regelmaessigOhneSenioren = removeCrossSectionDuplicates(seniorenDetails, mergedRegelmaessigDetails); const regelmaessigOhneJugend = removeCrossSectionDuplicates(kinderUndJugendDetails, regelmaessigOhneSenioren); const miriamtreffDetails = dedupeBySignature(miriamtreffLines); const regelmaessigBereinigt = removeCrossSectionDuplicates(miriamtreffDetails, regelmaessigOhneJugend); const parsedWorshipBlocks = extractWorshipBlocks(cleanedGottesdienste); const result = { gottesdienste: parsedWorshipBlocks, regelmaessigeTermine: regelmaessigBereinigt, besondereGottesdienste: extractEventCandidates(cleanedBesondere), miriamtreff: miriamtreffDetails, kinderUndJugend: kinderUndJugendDetails, frauenfruehstueck: frauenfruehstueckLines, senioren: seniorenDetails, }; const details = { gottesdienste: parsedWorshipBlocks, regelmaessigeTermine: regelmaessigBereinigt, besondereGottesdienste: buildDetailedItems(cleanedBesondere), miriamtreff: miriamtreffDetails, kinderUndJugend: kinderUndJugendDetails, frauenfruehstueck: frauenfruehstueckLines, senioren: seniorenDetails, sectionInfo: { gottesdiensteLines: gottesdiensteLines.length, regelmaessigLines: regelmaessigLines.length, seniorenKeywordLines: seniorenKeywordLines.length, besondereLines: besondereLines.length, kinderJugendLines: kinderJugendLines.length, } }; const questions = []; if (result.gottesdienste.length === 0) { questions.push('Keine Gottesdienste sicher extrahiert. Abschnittsgrenze oder Muster prüfen.'); } if (result.regelmaessigeTermine.length === 0) { questions.push('Regelmäßige Termine leer. Soll dieser Bereich seitenübergreifend weiter gefasst werden?'); } if (result.besondereGottesdienste.length === 0) { questions.push('Besondere Gottesdienste leer. Eventuell weitere Muster/Orte notwendig.'); } if (result.miriamtreff.length === 0) { questions.push('Miriamtreff nicht gefunden. Soll auch "Männer und Frauen" als Fallback gelten?'); } if (result.kinderUndJugend.length === 0) { questions.push('Kinder/Jugend leer. Soll zusätzlich der Abschnitt "Kinder und Jugend" (Seite 19) priorisiert werden?'); } res.status(200).json({ message: 'PDF geparst. Bitte Vorschau prüfen und offene Fragen beantworten.', parsed: result, details, questions, meta: { pages: parsed.numpages || null, lineCount: lines.length, }, }); } catch (error) { console.error('Fehler beim PDF-Import des Gemeindebriefs:', error); res.status(500).json({ message: 'Fehler beim Parsen der PDF-Datei.', error: error.message }); } }; // Export-Funktion für Gottesdienste exports.exportWorships = async (req, res) => { try { const { from, to, format } = req.query; if (!from || !to) { return res.status(400).json({ message: 'Von- und Bis-Datum müssen angegeben werden.' }); } const fromDate = new Date(from); const toDate = new Date(to); toDate.setHours(23, 59, 59, 999); // Bis Ende des Tages if (fromDate > toDate) { return res.status(400).json({ message: 'Das Von-Datum muss vor dem Bis-Datum liegen.' }); } // Gottesdienste im Datumsbereich abrufen const worships = await Worship.findAll({ where: { date: { [Op.between]: [fromDate, toDate] } }, include: { model: EventPlace, as: 'eventPlace', }, order: [ ['date', 'ASC'], ['time', 'ASC'] ], }); if (worships.length === 0) { return res.status(404).json({ message: 'Keine Gottesdienste im angegebenen Zeitraum gefunden.' }); } // Datum formatieren für Anzeige const formatDate = (date) => { const d = new Date(date); return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }; const formatTime = (time) => { if (!time) return ''; const parts = time.split(':'); return `${parts[0]}:${parts[1]}`; }; // Hex zu RGB konvertieren const hexToRgb = (hex) => { if (!hex) return null; const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null; }; // Gottesdienste nach Datum gruppieren für rowspan-Berechnung const worshipsByDate = {}; worships.forEach(worship => { const dateKey = formatDate(worship.date); if (!worshipsByDate[dateKey]) { worshipsByDate[dateKey] = []; } worshipsByDate[dateKey].push(worship); }); // Tabellenzeilen erstellen const tableRows = []; worships.forEach((worship, index) => { const dateKey = formatDate(worship.date); const isFirstWorshipOfDay = worshipsByDate[dateKey][0] === worship; const rowspan = worshipsByDate[dateKey].length; const dateStr = formatDate(worship.date); const dayNameStr = worship.dayName || ''; // Erste Spalte: Datum + Tag-Name // Formatierungen aus der geparsten Datei: Schriftart Arial, Größe 11pt, fett // Breite: 3,45 cm = 1956 DXA (1 cm = 567 DXA) let firstCell; if (isFirstWorshipOfDay) { // Erste Zelle des Tages: VerticalMerge RESTART (startet die Verbindung) // Tag-Name in neuer Zeile const firstCellChildren = [ new TextRun({ text: dateStr, color: '000000', font: 'Arial', size: 22, // 11pt = 22 half-points bold: true }) ]; if (dayNameStr) { firstCellChildren.push(new TextRun({ text: '', break: 1 // Zeilenumbruch })); firstCellChildren.push(new TextRun({ text: dayNameStr, color: '000000', font: 'Arial', size: 22, bold: true })); } firstCell = new TableCell({ children: [new Paragraph({ children: firstCellChildren, alignment: AlignmentType.CENTER, // Spalte 1 zentriert })], // Breite wird über columnWidths auf Tabellenebene gesetzt margins: { top: 140, bottom: 140, left: 140, right: 140 }, // 6mm Padding verticalMerge: rowspan > 1 ? VerticalMergeType.RESTART : undefined, verticalAlign: VerticalAlign.TOP }); } else { // Nachfolgende Zellen: mit VerticalMerge CONTINUE verbinden firstCell = new TableCell({ children: [new Paragraph({ text: '', alignment: AlignmentType.CENTER, // Spalte 1 zentriert })], // Breite wird über columnWidths auf Tabellenebene gesetzt margins: { top: 140, bottom: 140, left: 140, right: 140 }, // 6mm Padding verticalMerge: VerticalMergeType.CONTINUE }); } // Zweite Spalte: Gottesdienst-Details // Hintergrundfarbe aus EventPlace-Einstellungen const backgroundColor = worship.eventPlace?.backgroundColor || '#ffffff'; const hexColor = backgroundColor.replace('#', '').toUpperCase(); // Hintergrundfarbe immer setzen (auch wenn weiß) // Prüfen, ob die Farbe gültig ist (6-stelliger Hex-Code) const validHexColor = hexColor.length === 6 ? hexColor : 'FFFFFF'; const shading = { fill: '#' + validHexColor, // Fill muss mit # beginnen type: ShadingType.SOLID, color: '#' + validHexColor // Color sollte die gleiche Farbe wie fill haben }; // Format: **bold** Uhrzeit + Titel, dann Gestaltung (bold), dann Kollekte (nicht bold) const timeStr = formatTime(worship.time); const titleStr = worship.title || 'Gottesdienst'; const organizerStr = worship.organizer || ''; const collectionStr = worship.collection || ''; const secondCellChildren = []; // Uhrzeit + Titel (bold, schwarz, Arial 11pt) secondCellChildren.push(new TextRun({ text: `${timeStr} ${titleStr}`, bold: true, color: '000000', font: 'Arial', size: 22 // 11pt = 22 half-points })); // Gestaltung (bold, schwarz, Arial 11pt) if (organizerStr) { secondCellChildren.push(new TextRun({ text: '', break: 1 // Zeilenumbruch })); secondCellChildren.push(new TextRun({ text: 'Gestaltung: ', bold: true, color: '000000', font: 'Arial', size: 22 })); secondCellChildren.push(new TextRun({ text: organizerStr, bold: true, color: '000000', font: 'Arial', size: 22 })); } // Kollekte (nicht bold, schwarz, Arial 11pt) if (collectionStr) { secondCellChildren.push(new TextRun({ text: '', break: 1 // Zeilenumbruch })); secondCellChildren.push(new TextRun({ text: 'Kollekte: ', color: '000000', font: 'Arial', size: 22 })); secondCellChildren.push(new TextRun({ text: collectionStr, color: '000000', font: 'Arial', size: 22 })); } const secondCell = new TableCell({ children: [new Paragraph({ children: secondCellChildren, alignment: AlignmentType.CENTER, // Spalte 2 zentriert })], // Breite wird über columnWidths auf Tabellenebene gesetzt shading: shading, // Shading vor margins setzen margins: { top: 140, bottom: 140, left: 140, right: 140 }, // 6mm Padding verticalAlign: VerticalAlign.TOP }); // Zeile erstellen - immer beide Spalten const rowChildren = [firstCell, secondCell]; tableRows.push(new TableRow({ children: rowChildren, height: { value: 1559, rule: HeightRule.EXACT } // 2,75 cm = 1559 DXA - Zeilenhöhe auf TableRow setzen })); }); // Word-Dokument erstellen // Seitenränder: 2 cm = 1134 DXA (1 cm = 567 DXA) // Verfügbare Breite: 21 cm (A4) - 2 cm links - 2 cm rechts = 17 cm = 9638 DXA // Spalte 1: 3,45 cm = 1956 DXA // Spalte 2: 13,55 cm = 7682 DXA const doc = new Document({ sections: [{ properties: { page: { margin: { top: 1134, // 2 cm right: 1134, // 2 cm bottom: 1134, // 2 cm left: 1134 // 2 cm } } }, children: [ new Paragraph({ text: `Gottesdienste ${formatDate(fromDate)} - ${formatDate(toDate)}`, heading: 'Heading1', alignment: AlignmentType.CENTER }), new Paragraph({ text: '' }), // Leerzeile new Table({ width: { size: 9638, type: WidthType.DXA }, // 17 cm = 3,45 cm (1956) + 13,55 cm (7682) columnWidths: [1956, 7682], // Explizite Spaltenbreiten in DXA borders: { top: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' }, bottom: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' }, left: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' }, right: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' }, insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' }, insideVertical: { style: BorderStyle.SINGLE, size: 1, color: 'D0D0D0' } }, rows: tableRows }) ] }] }); // Dokument als Buffer generieren const buffer = await Packer.toBuffer(doc); // Dateiname generieren const filename = `gottesdienste_${from}_${to}_${format}.docx`; // Response senden res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); } catch (error) { console.error('Fehler beim Exportieren der Gottesdienste:', error); res.status(500).json({ message: 'Fehler beim Exportieren der Gottesdienste', error: error.message }); } };