durch Zeilenumbrüche (aber nicht doppelte)
cellText = cellText.replace(/<\/p>/gi, '\n');
cellText = cellText.replace(/
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 });
}
};