Enhance event management and newsletter import functionality: Introduce methods for applying drafts from a bulk queue and streamline event form handling. Update event selection logic in the newsletter import management component to support encoding and decoding of bulk selections, improving user experience and data handling.

This commit is contained in:
Torsten Schulz (local)
2026-04-08 14:22:01 +02:00
parent 1be6fe0afc
commit fb4f5e42d0
3 changed files with 386 additions and 104 deletions

View File

@@ -1323,7 +1323,13 @@ function normalizeText(input) {
}
function isHeading(line, heading) {
return normalizeText(line) === normalizeText(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 = []) {
@@ -1370,26 +1376,97 @@ function hasDateOrTime(line) {
function buildDetailedItems(lines) {
const result = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
const current = lines[i];
if (!hasDateOrTime(current)) continue;
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 || ''));
const prev = i > 0 ? lines[i - 1] : '';
const next = i + 1 < lines.length ? lines[i + 1] : '';
for (let i = 0; i < expandedLines.length; i++) {
const current = expandedLines[i];
if (!isEntryStart(current)) continue;
const parts = [];
if (prev && !hasDateOrTime(prev) && !looksLikeHeading(prev) && prev.length < 120) {
parts.push(prev);
}
parts.push(current);
if (next && !hasDateOrTime(next) && !looksLikeHeading(next) && next.length < 120) {
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(' | ');
const key = text.toLowerCase();
if (!seen.has(key)) {
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(text);
result.push(textCutAtInlineBoundary);
}
}
return result;
@@ -1451,12 +1528,41 @@ function extractRegularTermineDetails(lines) {
/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();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!anchors.some((r) => r.test(line))) continue;
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;
@@ -1464,9 +1570,11 @@ function extractRegularTermineDetails(lines) {
const parts = [line];
let hasScheduleSignal = hasDateOrTime(line) || /termine[:\s]/i.test(line);
for (let j = i + 1; j < Math.min(lines.length, i + 3); j++) {
const next = lines[j];
if (looksLikeHeading(next) || isNoiseLine(next)) break;
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;
@@ -1606,6 +1714,71 @@ function extractWorshipBlocks(lines) {
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) {
@@ -1638,7 +1811,8 @@ exports.importNewsletterPdf = async (req, res) => {
'Männer und Frauen',
['Musik', 'Kinder und Jugendliche']
);
const regelmaessigLines = [...regelmaessigSection, ...maennerFrauenSection];
const seniorenKeywordLines = extractLinesByKeyword(lines, /seniorenclub|senioren-?caf[eé]/i);
const regelmaessigLines = [...regelmaessigSection, ...maennerFrauenSection, ...seniorenKeywordLines];
const besondereLines = getSectionByHeading(
lines,
@@ -1662,29 +1836,39 @@ exports.importNewsletterPdf = async (req, res) => {
const cleanedBesondere = filterNoise(besondereLines);
const cleanedKinderJugend = filterNoise(kinderJugendLines);
const regelmaessigDetails = extractRegularTermineDetails(cleanedRegelmaessig);
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: regelmaessigDetails,
regelmaessigeTermine: regelmaessigBereinigt,
besondereGottesdienste: extractEventCandidates(cleanedBesondere),
miriamtreff: miriamtreffLines,
kinderUndJugend: extractEventCandidates(cleanedKinderJugend),
miriamtreff: miriamtreffDetails,
kinderUndJugend: kinderUndJugendDetails,
frauenfruehstueck: frauenfruehstueckLines,
senioren: seniorenDetails,
};
const details = {
gottesdienste: parsedWorshipBlocks,
regelmaessigeTermine: regelmaessigDetails,
regelmaessigeTermine: regelmaessigBereinigt,
besondereGottesdienste: buildDetailedItems(cleanedBesondere),
miriamtreff: miriamtreffLines,
kinderUndJugend: buildDetailedItems(cleanedKinderJugend),
miriamtreff: miriamtreffDetails,
kinderUndJugend: kinderUndJugendDetails,
frauenfruehstueck: frauenfruehstueckLines,
senioren: seniorenDetails,
sectionInfo: {
gottesdiensteLines: gottesdiensteLines.length,
regelmaessigLines: regelmaessigLines.length,
seniorenKeywordLines: seniorenKeywordLines.length,
besondereLines: besondereLines.length,
kinderJugendLines: kinderJugendLines.length,
}