Enhance worship import functionality: Add support for importing .xlsx files in worship management, updating UI to reflect new file type acceptance. Introduce new parsing logic for NBR planning records and update relevant routes and controllers to handle the new import process.
All checks were successful
Deploy miriamgemeinde / deploy (push) Successful in 7s

This commit is contained in:
Torsten Schulz (local)
2026-04-29 18:27:58 +02:00
parent 7f01c004c8
commit ddf05bd0e0
5 changed files with 326 additions and 172 deletions

View File

@@ -6,7 +6,8 @@ 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 JSZip = require('jszip');
const { DOMParser } = require('@xmldom/xmldom');
const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, WidthType, AlignmentType, VerticalAlign, ShadingType, VerticalMerge, VerticalMergeType, FontFamily, HeadingLevel, PageMargin, SectionType, BorderStyle, HeightRule } = require('docx');
function isAuthorized(req) {
@@ -246,6 +247,18 @@ function parseGermanDateString(dateString) {
return new Date(Date.UTC(year, month, day));
}
function parseNbrDateCell(value) {
const germanDate = parseGermanDateString(value);
if (germanDate) return germanDate;
const numericValue = Number(String(value || '').trim());
if (!Number.isFinite(numericValue) || numericValue <= 0) return null;
// Excel/LibreOffice serial date, including Excel's 1900 leap-year compatibility offset.
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return new Date(Date.UTC(1899, 11, 30) + Math.round(numericValue) * millisecondsPerDay);
}
function normalizeText(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
@@ -253,6 +266,139 @@ function normalizeText(value) {
.trim();
}
function getXmlText(node) {
if (!node) return '';
let result = '';
const collect = (current) => {
if (current.nodeType === 3 || current.nodeType === 4) {
result += current.nodeValue;
}
for (let child = current.firstChild; child; child = child.nextSibling) {
collect(child);
}
};
collect(node);
return result;
}
function columnIndexFromCellRef(cellRef) {
const match = String(cellRef || '').match(/[A-Z]+/i);
if (!match) return 0;
let index = 0;
for (const char of match[0].toUpperCase()) {
index = index * 26 + char.charCodeAt(0) - 64;
}
return index - 1;
}
function normalizeXlsxTarget(target) {
const normalized = String(target || '').replace(/^\/+/, '');
if (normalized.startsWith('xl/')) return normalized;
return `xl/${normalized}`;
}
function parseCsvRecords(csvText) {
const firstLine = String(csvText || '').split(/\r?\n/, 1)[0] || '';
const delimiterCounts = [',', ';', '\t'].map((delimiter) => ({
delimiter,
count: firstLine.split(delimiter).length - 1,
}));
const delimiter = delimiterCounts.sort((a, b) => b.count - a.count)[0].delimiter;
const records = [];
let row = [];
let value = '';
let inQuotes = false;
for (let index = 0; index < csvText.length; index++) {
const char = csvText[index];
const nextChar = csvText[index + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
value += '"';
index++;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && char === delimiter) {
row.push(value);
value = '';
continue;
}
if (!inQuotes && (char === '\n' || char === '\r')) {
if (char === '\r' && nextChar === '\n') index++;
row.push(value);
records.push(row);
row = [];
value = '';
continue;
}
value += char;
}
row.push(value);
if (row.some((cell) => cell !== '') || records.length === 0) {
records.push(row);
}
return records;
}
async function parseXlsxToRecords(buffer) {
const zip = await JSZip.loadAsync(buffer);
const parser = new DOMParser();
const sharedStringsFile = zip.file('xl/sharedStrings.xml');
let sharedStrings = [];
if (sharedStringsFile) {
const sharedDoc = parser.parseFromString(await sharedStringsFile.async('string'), 'text/xml');
sharedStrings = Array.from(sharedDoc.getElementsByTagName('si')).map(getXmlText);
}
let worksheetPath = 'xl/worksheets/sheet1.xml';
const relationshipsFile = zip.file('xl/_rels/workbook.xml.rels');
if (relationshipsFile) {
const relationshipsDoc = parser.parseFromString(await relationshipsFile.async('string'), 'text/xml');
const firstSheetRel = Array.from(relationshipsDoc.getElementsByTagName('Relationship'))
.find((relationship) => /\/worksheet$/i.test(relationship.getAttribute('Type') || ''));
if (firstSheetRel?.getAttribute('Target')) {
worksheetPath = normalizeXlsxTarget(firstSheetRel.getAttribute('Target'));
}
}
const worksheetFile = zip.file(worksheetPath);
if (!worksheetFile) {
throw new Error('Kein Arbeitsblatt in der XLSX-Datei gefunden.');
}
const worksheetDoc = parser.parseFromString(await worksheetFile.async('string'), 'text/xml');
return Array.from(worksheetDoc.getElementsByTagName('row')).map((row) => {
const values = [];
for (const cell of Array.from(row.getElementsByTagName('c'))) {
const cellRef = cell.getAttribute('r');
const type = cell.getAttribute('t');
const valueNode = cell.getElementsByTagName('v')[0];
const inlineStringNode = cell.getElementsByTagName('is')[0];
let value = '';
if (type === 's') {
value = sharedStrings[Number(getXmlText(valueNode))] || '';
} else if (type === 'inlineStr') {
value = getXmlText(inlineStringNode);
} else {
value = getXmlText(valueNode);
}
values[columnIndexFromCellRef(cellRef)] = value;
}
return values;
});
}
function buildLeaderMaps(leaders) {
const codeToName = new Map();
const normalizedToName = new Map();
@@ -324,7 +470,7 @@ function parseNbrSegment(segment, baseDateUtc, leaderNormalizedMap) {
// 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);
const timeMatch = text.match(/(\d{1,2})\s*(?:[:.]\s*(\d{2})|h)(?=\D|$)/i);
if (timeMatch) {
const hours = parseInt(timeMatch[1], 10);
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
@@ -1327,182 +1473,186 @@ exports.importWorships = async (req, res) => {
}
};
// Import-Funktion für Gottesdienste aus dem neuen NBR-CSV Format (2026+)
exports.importWorshipsNbrCsv = async (req, res) => {
async function parseNbrPlanningRecords(records) {
if (!Array.isArray(records) || records.length < 3) {
throw new Error('Datei 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 = parseNbrDateCell(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;
});
return { worships: worshipsForFrontend, errors };
}
// Import-Funktion für Gottesdienste aus dem neuen NBR-XLSX/CSV Format (2026+)
exports.importWorshipsNbrPlanning = 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.' });
if (!fileName.endsWith('.xlsx') && !fileName.endsWith('.csv')) {
return res.status(400).json({ message: 'Nur .xlsx oder .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;
});
const records = fileName.endsWith('.xlsx')
? await parseXlsxToRecords(req.file.buffer)
: parseCsvRecords(req.file.buffer.toString('utf8'));
const parsed = await parseNbrPlanningRecords(records);
res.status(200).json({
message: `CSV geparst. ${worshipsForFrontend.length} Einträge mit Änderungen gefunden.`,
worships: worshipsForFrontend,
errors: errors.length ? errors : undefined,
message: `${fileName.endsWith('.xlsx') ? 'XLSX' : 'CSV'} geparst. ${parsed.worships.length} Einträge mit Änderungen gefunden.`,
worships: parsed.worships,
errors: parsed.errors.length ? parsed.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 });
console.error('Fehler beim Importieren (NBR Planung):', error);
res.status(500).json({ message: 'Fehler beim Importieren der Planungsdatei', error: error.message });
}
};
exports.importWorshipsNbrCsv = exports.importWorshipsNbrPlanning;
// Funktion zum Speichern der bearbeiteten Gottesdienste
exports.saveImportedWorships = async (req, res) => {
try {

2
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"@tiptap/vue-3": "^3.22.2",
"@xmldom/xmldom": "^0.8.12",
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
@@ -36,6 +37,7 @@
"express": "^5.2.1",
"file-saver": "^2.0.5",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mammoth": "^1.11.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1",

View File

@@ -28,6 +28,7 @@
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"@tiptap/vue-3": "^3.22.2",
"@xmldom/xmldom": "^0.8.12",
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
@@ -39,12 +40,12 @@
"express": "^5.2.1",
"file-saver": "^2.0.5",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mammoth": "^1.11.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1",
"nodemailer": "^7.0.6",
"pdf-parse": "^1.1.1",
"csv-parse": "^5.6.0",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
"uuid": "^10.0.0",

View File

@@ -1,12 +1,13 @@
const express = require('express');
const router = express.Router();
const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, importWorshipsNbrCsv, uploadImportFile, exportWorships, saveImportedWorships, importNewsletterPdf } = require('../controllers/worshipController');
const { getAllWorships, createWorship, updateWorship, deleteWorship, getFilteredWorships, getWorshipOptions, importWorships, importWorshipsNbrCsv, importWorshipsNbrPlanning, uploadImportFile, exportWorships, saveImportedWorships, importNewsletterPdf } = require('../controllers/worshipController');
const authMiddleware = require('../middleware/authMiddleware');
router.get('/', getAllWorships);
router.get('/options', getWorshipOptions);
router.post('/', authMiddleware, createWorship);
router.post('/import', authMiddleware, uploadImportFile, importWorships);
router.post('/import/nbr-planning', authMiddleware, uploadImportFile, importWorshipsNbrPlanning);
router.post('/import/nbr-csv', authMiddleware, uploadImportFile, importWorshipsNbrCsv);
router.post('/import/newsletter-pdf', authMiddleware, uploadImportFile, importNewsletterPdf);
router.post('/import/save', authMiddleware, saveImportedWorships);

View File

@@ -15,13 +15,13 @@
<div v-if="showImportSection" class="import-section">
<h3>Gottesdienste importieren</h3>
<div class="import-content">
<label for="import-file">Datei auswählen (.doc, .docx, .csv):</label>
<label for="import-file">Datei auswählen (.doc, .docx, .xlsx):</label>
<input
type="file"
id="import-file"
ref="fileInput"
@change="handleFileSelect"
accept=".doc,.docx,.csv"
accept=".doc,.docx,.xlsx"
/>
<div v-if="selectedFile" class="selected-file">
Ausgewählte Datei: {{ selectedFile.name }}
@@ -730,13 +730,13 @@ export default {
handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
// Validierung: .docx (alt) oder .csv (neu) erlauben
const allowedExtensions = ['.doc', '.docx', '.csv'];
// Validierung: .docx (alt) oder .xlsx (neue NBR-Planung) erlauben
const allowedExtensions = ['.doc', '.docx', '.xlsx'];
const fileName = file.name.toLowerCase();
const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext));
if (!isValidFile) {
alert('Bitte wählen Sie eine .doc/.docx oder .csv Datei aus.');
alert('Bitte wählen Sie eine .doc/.docx oder .xlsx Datei aus.');
event.target.value = '';
this.selectedFile = null;
return;
@@ -759,8 +759,8 @@ export default {
try {
const fileName = this.selectedFile.name.toLowerCase();
const endpoint = fileName.endsWith('.csv')
? '/worships/import/nbr-csv'
const endpoint = fileName.endsWith('.xlsx')
? '/worships/import/nbr-planning'
: '/worships/import';
const response = await axios.post(endpoint, formData, {