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 JSZip = require('jszip'); const { DOMParser } = require('@xmldom/xmldom'); 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 parseNbrDateCell(value) { const germanDate = parseGermanDateString(value); if (germanDate) return germanDate; const numericValue = Number(String(value || '').trim()); if (!Number.isFinite(numericValue) || numericValue <= 0) return null; // Excel/LibreOffice serial date, including Excel's 1900 leap-year compatibility offset. const millisecondsPerDay = 24 * 60 * 60 * 1000; return new Date(Date.UTC(1899, 11, 30) + Math.round(numericValue) * millisecondsPerDay); } function normalizeText(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function getXmlText(node) { if (!node) return ''; let result = ''; const collect = (current) => { if (current.nodeType === 3 || current.nodeType === 4) { result += current.nodeValue; } for (let child = current.firstChild; child; child = child.nextSibling) { collect(child); } }; collect(node); return result; } function columnIndexFromCellRef(cellRef) { const match = String(cellRef || '').match(/[A-Z]+/i); if (!match) return 0; let index = 0; for (const char of match[0].toUpperCase()) { index = index * 26 + char.charCodeAt(0) - 64; } return index - 1; } function normalizeXlsxTarget(target) { const normalized = String(target || '').replace(/^\/+/, ''); if (normalized.startsWith('xl/')) return normalized; return `xl/${normalized}`; } function parseCsvRecords(csvText) { const firstLine = String(csvText || '').split(/\r?\n/, 1)[0] || ''; const delimiterCounts = [',', ';', '\t'].map((delimiter) => ({ delimiter, count: firstLine.split(delimiter).length - 1, })); const delimiter = delimiterCounts.sort((a, b) => b.count - a.count)[0].delimiter; const records = []; let row = []; let value = ''; let inQuotes = false; for (let index = 0; index < csvText.length; index++) { const char = csvText[index]; const nextChar = csvText[index + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { value += '"'; index++; } else { inQuotes = !inQuotes; } continue; } if (!inQuotes && char === delimiter) { row.push(value); value = ''; continue; } if (!inQuotes && (char === '\n' || char === '\r')) { if (char === '\r' && nextChar === '\n') index++; row.push(value); records.push(row); row = []; value = ''; continue; } value += char; } row.push(value); if (row.some((cell) => cell !== '') || records.length === 0) { records.push(row); } return records; } async function parseXlsxToRecords(buffer) { const zip = await JSZip.loadAsync(buffer); const parser = new DOMParser(); const sharedStringsFile = zip.file('xl/sharedStrings.xml'); let sharedStrings = []; if (sharedStringsFile) { const sharedDoc = parser.parseFromString(await sharedStringsFile.async('string'), 'text/xml'); sharedStrings = Array.from(sharedDoc.getElementsByTagName('si')).map(getXmlText); } let worksheetPath = 'xl/worksheets/sheet1.xml'; const relationshipsFile = zip.file('xl/_rels/workbook.xml.rels'); if (relationshipsFile) { const relationshipsDoc = parser.parseFromString(await relationshipsFile.async('string'), 'text/xml'); const firstSheetRel = Array.from(relationshipsDoc.getElementsByTagName('Relationship')) .find((relationship) => /\/worksheet$/i.test(relationship.getAttribute('Type') || '')); if (firstSheetRel?.getAttribute('Target')) { worksheetPath = normalizeXlsxTarget(firstSheetRel.getAttribute('Target')); } } const worksheetFile = zip.file(worksheetPath); if (!worksheetFile) { throw new Error('Kein Arbeitsblatt in der XLSX-Datei gefunden.'); } const worksheetDoc = parser.parseFromString(await worksheetFile.async('string'), 'text/xml'); return Array.from(worksheetDoc.getElementsByTagName('row')).map((row) => { const values = []; for (const cell of Array.from(row.getElementsByTagName('c'))) { const cellRef = cell.getAttribute('r'); const type = cell.getAttribute('t'); const valueNode = cell.getElementsByTagName('v')[0]; const inlineStringNode = cell.getElementsByTagName('is')[0]; let value = ''; if (type === 's') { value = sharedStrings[Number(getXmlText(valueNode))] || ''; } else if (type === 'inlineStr') { value = getXmlText(inlineStringNode); } else { value = getXmlText(valueNode); } values[columnIndexFromCellRef(cellRef)] = value; } return values; }); } function buildLeaderMaps(leaders) { const codeToName = new Map(); const normalizedToName = new Map(); const matchEntries = []; 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); matchEntries.push({ key: code.toLowerCase(), name }); } if (name) { normalizedToName.set(name.toLowerCase(), name); matchEntries.push({ key: name.toLowerCase(), name }); } const aliases = String(leader.aliases || '') .split(',') .map((x) => normalizeText(x)) .filter(Boolean); for (const alias of aliases) { normalizedToName.set(alias.toLowerCase(), name); matchEntries.push({ key: alias.toLowerCase(), name }); } } // Längere Keys zuerst (z.B. ganzer Name vor Kürzel). matchEntries.sort((a, b) => b.key.length - a.key.length); return { codeToName, normalizedToName, matchEntries }; } function escapeRegex(text) { return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function resolveLeaderFromText(text, leaderMaps) { const source = normalizeText(text); const lowerSource = source.toLowerCase(); let resolvedName = ''; let cleaned = source; for (const entry of leaderMaps?.matchEntries || []) { const key = entry.key; if (!key) continue; const boundaryPattern = new RegExp(`(^|\\s|[(/,-])${escapeRegex(key)}($|\\s|[)/,-])`, 'i'); if (!boundaryPattern.test(lowerSource)) continue; resolvedName = entry.name; cleaned = cleaned.replace(new RegExp(`\\b${escapeRegex(key)}\\b`, 'ig'), ' ').replace(/\s+/g, ' ').trim(); break; } return { resolvedName, cleanedText: cleaned }; } function buildWorshipTitleFromPlace(placeName, fallback = 'Gottesdienst') { const raw = normalizeText(placeName); if (!raw) return fallback; let location = raw; let churchLabel = 'Evangelischen Kirche'; // Typische Muster: "Evangelische Kirche Nieder-Eschbach" -> "Nieder-Eschbach" const afterKirche = raw.match(/kirche\s+(.+)$/i); if (afterKirche && afterKirche[1]) { location = normalizeText(afterKirche[1]); } else { const parts = raw.split(/\s*(?:,|\/|->|\(|\)|-)\s*/).filter(Boolean); if (parts.length > 0) { location = normalizeText(parts[parts.length - 1]); } } if (!location) return fallback; // Wenn nur Ortsteil erkannt wurde, als Kirchenort formulieren. // So vermeiden wir Titel wie "Gemeindehaus ...", auch wenn der EventPlace so heißt. const normalizedLocation = location .replace(/^evangelisch(?:e|es|er)?\s+(?:gemeindehaus|gemeindezentrum)\s*/i, '') .replace(/^evangelisch(?:e|es|er)?\s+kirche\s*/i, '') .replace(/^kirche\s*/i, '') .trim(); // Ergänzende Kirchennamen erkennen, z. B. "Evangelische Friedenskirche Harheim". const labelMatch = raw.match(/(evangelisch(?:e|es|er)?\s+[a-zäöüß-]*kirche)\b/i); if (labelMatch && labelMatch[1]) { churchLabel = normalizeText(labelMatch[1]); } else if (/\bfriedenskirche\b/i.test(raw)) { churchLabel = 'Evangelischen Friedenskirche'; } const finalLocation = normalizedLocation || location; return `Gottesdienst in der ${churchLabel} ${finalLocation}`; } function resolveEventPlaceIdFromHeader(eventPlaces, headerCell) { const raw = normalizeText(headerCell); if (!raw) return null; const nameOnly = normalizeText(raw.split('(')[0]); const normalized = nameOnly.toLowerCase(); const places = eventPlaces || []; // 1) Schnellpfad: exakter Name. const exact = places.find((p) => normalizeText(p.name).toLowerCase() === normalized); if (exact) return exact.id; // 2) DB-basierter Score statt harter Sonderfalllisten. const tokens = normalized .replace(/[^a-z0-9äöüß\- ]/gi, ' ') .split(/\s+/) .filter((t) => t.length >= 3); const scorePlace = (placeName) => { const n = normalizeText(placeName).toLowerCase(); let score = 0; // Starker Treffer für ganze Header-Zeile im Ortsnamen. if (n.includes(normalized)) score += 120; // Token-Überlappung (Ortsteile etc.). for (const t of tokens) { if (n.includes(t)) score += 20; } // Für Gottesdienst-Orte: Kirche bevorzugen, Gemeinde* leicht abwerten. if (/\bkirche\b/.test(n)) score += 40; if (/\bevangelisch/.test(n)) score += 15; if (/\bgemeindeb[uü]ro\b/.test(n)) score -= 25; if (/\bgemeindehaus\b/.test(n)) score -= 20; if (/\bgemeindezentrum\b/.test(n)) score -= 20; return score; }; let best = null; let bestScore = -Infinity; for (const place of places) { const s = scorePlace(place.name || ''); if (s > bestScore) { bestScore = s; best = place; } } // Mindestschwelle: verhindert Zufallstreffer ohne Ortsbezug. if (best && bestScore >= 25) { return best.id; } // 3) Kleiner, defensiver Fallback für bekannte Ortsnamen. if (/am b[üu]gel/i.test(raw)) return 12; if (/nieder-eschbach/i.test(raw)) return 14; if (/nieder-erlenbach/i.test(raw)) return 13; if (/harheim/i.test(raw)) return 15; if (/kalbach/i.test(raw)) return 11; if (/bonames/i.test(raw)) return 1; 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, leaderMaps) { 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)(?=\D|$)/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], '')); } // Gestalter aus beliebigem Teilstring auflösen (Kürzel/Name/Alias). const { resolvedName, cleanedText } = resolveLeaderFromText(text, leaderMaps); const officiant = resolvedName || ''; const unparsedText = cleanedText; return { dateUtc, time, title: 'Gottesdienst', officiant, sourceText: raw, unparsedText, }; } // 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 = /