diff --git a/controllers/worshipController.js b/controllers/worshipController.js index 58ebff3..1387c2c 100644 --- a/controllers/worshipController.js +++ b/controllers/worshipController.js @@ -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 { diff --git a/package-lock.json b/package-lock.json index 853078c..a6b3b0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c715a7c..afe1e38 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routes/worships.js b/routes/worships.js index da78f49..35f9ec0 100644 --- a/routes/worships.js +++ b/routes/worships.js @@ -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); diff --git a/src/content/admin/WorshipManagement.vue b/src/content/admin/WorshipManagement.vue index 72fad46..f73cf82 100644 --- a/src/content/admin/WorshipManagement.vue +++ b/src/content/admin/WorshipManagement.vue @@ -15,13 +15,13 @@