Files
miriamgemeinde/controllers/worshipController.js

1529 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { Worship, EventPlace, LiturgicalDay, 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 { 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 } = 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
}
}
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;
}
// 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();
// 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: ''
};
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):/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äöüß-]+)$/, '');
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, '');
worship.title = title.substring(0, 100) || '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].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;
}
// 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;
}
}
}
// 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 = /<table[^>]*>([\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 = /<tr[^>]*>([\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 = /<t[dh][^>]*>([\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 <br> und <br/> durch Zeilenumbrüche
cellText = cellText.replace(/<br\s*\/?>/gi, '\n');
// Ersetze </p> und <p> durch Zeilenumbrüche (aber nicht doppelte)
cellText = cellText.replace(/<\/p>/gi, '\n');
cellText = cellText.replace(/<p[^>]*>/gi, '');
// Ersetze </div> und <div> durch Zeilenumbrüche
cellText = cellText.replace(/<\/div>/gi, '\n');
cellText = cellText.replace(/<div[^>]*>/gi, '');
// Entferne andere HTML-Tags
cellText = cellText.replace(/<[^>]+>/g, '');
// HTML-Entities ersetzen
cellText = cellText.replace(/&nbsp;/g, ' ');
cellText = cellText.replace(/&amp;/g, '&');
cellText = cellText.replace(/&lt;/g, '<');
cellText = cellText.replace(/&gt;/g, '>');
cellText = cellText.replace(/&quot;/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 <Ort>"
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 });
}
};
// 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
}
// approved auf false setzen
worshipData.approved = false;
// 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 });
}
};
// 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 });
}
};