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