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
All checks were successful
Deploy miriamgemeinde / deploy (push) Successful in 7s
This commit is contained in:
@@ -6,7 +6,8 @@ const multer = require('multer');
|
|||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
const mammoth = require('mammoth');
|
const mammoth = require('mammoth');
|
||||||
const pdfParse = require('pdf-parse');
|
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');
|
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) {
|
function isAuthorized(req) {
|
||||||
@@ -246,6 +247,18 @@ function parseGermanDateString(dateString) {
|
|||||||
return new Date(Date.UTC(year, month, day));
|
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) {
|
function normalizeText(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/\u00a0/g, ' ')
|
.replace(/\u00a0/g, ' ')
|
||||||
@@ -253,6 +266,139 @@ function normalizeText(value) {
|
|||||||
.trim();
|
.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) {
|
function buildLeaderMaps(leaders) {
|
||||||
const codeToName = new Map();
|
const codeToName = new Map();
|
||||||
const normalizedToName = 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.
|
// Time: allow 10.30, 10:30, 10 h, 10h, 10.30 Uhr, etc.
|
||||||
let time = null;
|
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) {
|
if (timeMatch) {
|
||||||
const hours = parseInt(timeMatch[1], 10);
|
const hours = parseInt(timeMatch[1], 10);
|
||||||
const minutes = timeMatch[2] ? parseInt(timeMatch[2], 10) : 0;
|
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+)
|
async function parseNbrPlanningRecords(records) {
|
||||||
exports.importWorshipsNbrCsv = async (req, res) => {
|
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 {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ message: 'Keine Datei hochgeladen.' });
|
return res.status(400).json({ message: 'Keine Datei hochgeladen.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = req.file.originalname.toLowerCase();
|
const fileName = req.file.originalname.toLowerCase();
|
||||||
if (!fileName.endsWith('.csv')) {
|
if (!fileName.endsWith('.xlsx') && !fileName.endsWith('.csv')) {
|
||||||
return res.status(400).json({ message: 'Nur .csv Dateien sind erlaubt.' });
|
return res.status(400).json({ message: 'Nur .xlsx oder .csv Dateien sind erlaubt.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvText = req.file.buffer.toString('utf8');
|
const records = fileName.endsWith('.xlsx')
|
||||||
const records = parseCsv(csvText, {
|
? await parseXlsxToRecords(req.file.buffer)
|
||||||
relax_quotes: true,
|
: parseCsvRecords(req.file.buffer.toString('utf8'));
|
||||||
relax_column_count: true,
|
const parsed = await parseNbrPlanningRecords(records);
|
||||||
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({
|
res.status(200).json({
|
||||||
message: `CSV geparst. ${worshipsForFrontend.length} Einträge mit Änderungen gefunden.`,
|
message: `${fileName.endsWith('.xlsx') ? 'XLSX' : 'CSV'} geparst. ${parsed.worships.length} Einträge mit Änderungen gefunden.`,
|
||||||
worships: worshipsForFrontend,
|
worships: parsed.worships,
|
||||||
errors: errors.length ? errors : undefined,
|
errors: parsed.errors.length ? parsed.errors : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Importieren (NBR CSV):', error);
|
console.error('Fehler beim Importieren (NBR Planung):', error);
|
||||||
res.status(500).json({ message: 'Fehler beim Importieren der CSV', error: error.message });
|
res.status(500).json({ message: 'Fehler beim Importieren der Planungsdatei', error: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.importWorshipsNbrCsv = exports.importWorshipsNbrPlanning;
|
||||||
|
|
||||||
// Funktion zum Speichern der bearbeiteten Gottesdienste
|
// Funktion zum Speichern der bearbeiteten Gottesdienste
|
||||||
exports.saveImportedWorships = async (req, res) => {
|
exports.saveImportedWorships = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.22.2",
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
"@tiptap/vue-3": "^3.22.2",
|
"@tiptap/vue-3": "^3.22.2",
|
||||||
|
"@xmldom/xmldom": "^0.8.12",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.10.1",
|
"mysql2": "^3.10.1",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@tiptap/extension-underline": "^3.22.2",
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
"@tiptap/vue-3": "^3.22.2",
|
"@tiptap/vue-3": "^3.22.2",
|
||||||
|
"@xmldom/xmldom": "^0.8.12",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
@@ -39,12 +40,12 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.10.1",
|
"mysql2": "^3.10.1",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"csv-parse": "^5.6.0",
|
|
||||||
"sequelize": "^6.37.3",
|
"sequelize": "^6.37.3",
|
||||||
"sequelize-cli": "^6.6.2",
|
"sequelize-cli": "^6.6.2",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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');
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
|
|
||||||
router.get('/', getAllWorships);
|
router.get('/', getAllWorships);
|
||||||
router.get('/options', getWorshipOptions);
|
router.get('/options', getWorshipOptions);
|
||||||
router.post('/', authMiddleware, createWorship);
|
router.post('/', authMiddleware, createWorship);
|
||||||
router.post('/import', authMiddleware, uploadImportFile, importWorships);
|
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/nbr-csv', authMiddleware, uploadImportFile, importWorshipsNbrCsv);
|
||||||
router.post('/import/newsletter-pdf', authMiddleware, uploadImportFile, importNewsletterPdf);
|
router.post('/import/newsletter-pdf', authMiddleware, uploadImportFile, importNewsletterPdf);
|
||||||
router.post('/import/save', authMiddleware, saveImportedWorships);
|
router.post('/import/save', authMiddleware, saveImportedWorships);
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
<div v-if="showImportSection" class="import-section">
|
<div v-if="showImportSection" class="import-section">
|
||||||
<h3>Gottesdienste importieren</h3>
|
<h3>Gottesdienste importieren</h3>
|
||||||
<div class="import-content">
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="import-file"
|
id="import-file"
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
accept=".doc,.docx,.csv"
|
accept=".doc,.docx,.xlsx"
|
||||||
/>
|
/>
|
||||||
<div v-if="selectedFile" class="selected-file">
|
<div v-if="selectedFile" class="selected-file">
|
||||||
Ausgewählte Datei: {{ selectedFile.name }}
|
Ausgewählte Datei: {{ selectedFile.name }}
|
||||||
@@ -730,13 +730,13 @@ export default {
|
|||||||
handleFileSelect(event) {
|
handleFileSelect(event) {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// Validierung: .docx (alt) oder .csv (neu) erlauben
|
// Validierung: .docx (alt) oder .xlsx (neue NBR-Planung) erlauben
|
||||||
const allowedExtensions = ['.doc', '.docx', '.csv'];
|
const allowedExtensions = ['.doc', '.docx', '.xlsx'];
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext));
|
const isValidFile = allowedExtensions.some(ext => fileName.endsWith(ext));
|
||||||
|
|
||||||
if (!isValidFile) {
|
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 = '';
|
event.target.value = '';
|
||||||
this.selectedFile = null;
|
this.selectedFile = null;
|
||||||
return;
|
return;
|
||||||
@@ -759,8 +759,8 @@ export default {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const fileName = this.selectedFile.name.toLowerCase();
|
const fileName = this.selectedFile.name.toLowerCase();
|
||||||
const endpoint = fileName.endsWith('.csv')
|
const endpoint = fileName.endsWith('.xlsx')
|
||||||
? '/worships/import/nbr-csv'
|
? '/worships/import/nbr-planning'
|
||||||
: '/worships/import';
|
: '/worships/import';
|
||||||
|
|
||||||
const response = await axios.post(endpoint, formData, {
|
const response = await axios.post(endpoint, formData, {
|
||||||
|
|||||||
Reference in New Issue
Block a user