diff --git a/controllers/worshipController.js b/controllers/worshipController.js index fa6b4aa..487546e 100644 --- a/controllers/worshipController.js +++ b/controllers/worshipController.js @@ -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, } diff --git a/src/content/admin/EventManagement.vue b/src/content/admin/EventManagement.vue index eab32b4..8f21834 100644 --- a/src/content/admin/EventManagement.vue +++ b/src/content/admin/EventManagement.vue @@ -128,6 +128,51 @@ export default { }, methods: { formatTime, + applyDraftToForm(draft) { + const resolvedEventPlace = + this.eventPlaces.find((place) => place.id === draft?.event_place_id) || + this.eventPlaces.find((place) => + draft?.event_place_name && + String(place?.name || '').toLowerCase().includes(String(draft.event_place_name).toLowerCase()) + ) || + null; + this.selectedEvent = { + name: draft?.name || '', + description: '', + date: draft?.date || '', + time: draft?.time || '', + endTime: draft?.endTime || '', + eventTypeId: draft?.eventTypeId ?? null, + event_place_id: resolvedEventPlace?.id ?? draft?.event_place_id ?? null, + eventPlace: resolvedEventPlace, + __newsletterDateMode: draft?.dateMode || null, + __newsletterBulkDates: draft?.bulkDates || '', + }; + this.showForm = true; + this.scrollToFormAndFocus(); + }, + getNextDraftFromBulkQueue() { + const rawQueue = localStorage.getItem('newsletter_import_event_bulk_queue'); + if (!rawQueue) return null; + try { + const queue = JSON.parse(rawQueue); + if (!Array.isArray(queue) || queue.length === 0) { + localStorage.removeItem('newsletter_import_event_bulk_queue'); + return null; + } + const [next, ...rest] = queue; + if (rest.length > 0) { + localStorage.setItem('newsletter_import_event_bulk_queue', JSON.stringify(rest)); + } else { + localStorage.removeItem('newsletter_import_event_bulk_queue'); + } + return next; + } catch (error) { + console.error('Fehler beim Lesen der Event-Bulk-Queue:', error); + localStorage.removeItem('newsletter_import_event_bulk_queue'); + return null; + } + }, async fetchData() { try { const [eventResponse, institutionResponse, eventPlaceResponse, contactPersonResponse, eventTypeResponse] = await Promise.all([ @@ -156,31 +201,18 @@ export default { this.$router.push('/admin/newsletter-import'); }, applyNewsletterDraft() { + const nextFromQueue = this.getNextDraftFromBulkQueue(); + if (nextFromQueue) { + this.applyDraftToForm(nextFromQueue); + return; + } + const raw = localStorage.getItem('newsletter_import_event_draft'); if (!raw) return; localStorage.removeItem('newsletter_import_event_draft'); try { const draft = JSON.parse(raw); - const resolvedEventPlace = - this.eventPlaces.find((place) => place.id === draft?.event_place_id) || - this.eventPlaces.find((place) => - draft?.event_place_name && - String(place?.name || '').toLowerCase().includes(String(draft.event_place_name).toLowerCase()) - ) || - null; - this.selectedEvent = { - name: draft?.name || '', - description: '', - date: draft?.date || '', - time: draft?.time || '', - eventTypeId: draft?.eventTypeId ?? null, - event_place_id: resolvedEventPlace?.id ?? draft?.event_place_id ?? null, - eventPlace: resolvedEventPlace, - __newsletterDateMode: draft?.dateMode || null, - __newsletterBulkDates: draft?.bulkDates || '', - }; - this.showForm = true; - this.scrollToFormAndFocus(); + this.applyDraftToForm(draft); } catch (error) { console.error('Fehler beim Übernehmen des Gemeindebrief-Entwurfs (Event):', error); } @@ -211,7 +243,12 @@ export default { } }, handleEventSaved() { - this.showForm = false; + const nextFromQueue = this.getNextDraftFromBulkQueue(); + if (nextFromQueue) { + this.applyDraftToForm(nextFromQueue); + } else { + this.showForm = false; + } this.fetchData(); }, handleEventCancelled() { diff --git a/src/content/admin/NewsletterImportManagement.vue b/src/content/admin/NewsletterImportManagement.vue index ecca76e..736d600 100644 --- a/src/content/admin/NewsletterImportManagement.vue +++ b/src/content/admin/NewsletterImportManagement.vue @@ -41,6 +41,7 @@