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,
}

View File

@@ -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() {

View File

@@ -41,6 +41,7 @@
<li>Miriamtreff: {{ newsletterImportResult.parsed?.miriamtreff?.length || 0 }}</li>
<li>Kinder und Jugend: {{ newsletterImportResult.parsed?.kinderUndJugend?.length || 0 }}</li>
<li>Frauenfrühstück: {{ newsletterImportResult.parsed?.frauenfruehstueck?.length || 0 }}</li>
<li>Senioren: {{ newsletterImportResult.parsed?.senioren?.length || 0 }}</li>
</ul>
<div v-if="newsletterImportResult.details" class="details">
@@ -60,7 +61,7 @@
<h5>Regelmäßige Termine</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.regelmaessigeTermine || []" :key="`r-${idx}`">
<label><input type="checkbox" :value="entry" v-model="selectedEventEntries" /></label>
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Regelmäßige Termine')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Regelmäßige Termine')">Als Event übernehmen</button>
</li>
@@ -71,7 +72,7 @@
<h5>Besondere Gottesdienste</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.besondereGottesdienste || []" :key="`b-${idx}`">
<label><input type="checkbox" :value="entry" v-model="selectedEventEntries" /></label>
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Besondere Gottesdienste')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Besondere Gottesdienste')">Als Event übernehmen</button>
</li>
@@ -82,7 +83,7 @@
<h5>Miriamtreff</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.miriamtreff || []" :key="`m-${idx}`">
<label><input type="checkbox" :value="entry" v-model="selectedEventEntries" /></label>
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Miriamtreff')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Miriamtreff')">Als Event übernehmen</button>
</li>
@@ -93,7 +94,7 @@
<h5>Kinder und Jugend</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.kinderUndJugend || []" :key="`k-${idx}`">
<label><input type="checkbox" :value="entry" v-model="selectedEventEntries" /></label>
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Kinder und Jugend')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Kinder und Jugend')">Als Event übernehmen</button>
</li>
@@ -104,12 +105,23 @@
<h5>Frauenfrühstück</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.frauenfruehstueck || []" :key="`f-${idx}`">
<label><input type="checkbox" :value="entry" v-model="selectedEventEntries" /></label>
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Frauenfrühstück')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Frauenfrühstück')">Als Event übernehmen</button>
</li>
</ul>
</div>
<div class="detail-group">
<h5>Senioren</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.senioren || []" :key="`s-${idx}`">
<label><input type="checkbox" :value="encodeBulkSelection(entry, 'Senioren')" v-model="selectedEventEntries" /></label>
<span>{{ entry }}</span>
<button type="button" class="transfer-button" @click="transferToEventForm(entry, 'Senioren')">Als Event übernehmen</button>
</li>
</ul>
</div>
</div>
<div v-if="newsletterImportResult.questions?.length" class="questions">
@@ -146,40 +158,105 @@ export default {
}
},
methods: {
encodeBulkSelection(entry, category) {
return `${category}|||${entry}`;
},
decodeBulkSelection(token) {
const value = String(token || '');
const sep = '|||';
const idx = value.indexOf(sep);
if (idx < 0) {
return { category: 'Regelmäßige Termine', entry: value };
}
return {
category: value.slice(0, idx),
entry: value.slice(idx + sep.length),
};
},
buildEventDraft(entry, category) {
const parsed = this.parseDateAndTime(entry);
const mapping = this.inferEventMapping(entry, category);
const allDates = this.extractAllDates(entry);
const hasMultipleDates = allDates.length > 1;
const inferredTitle = this.extractTitle(entry);
const normalizedTitle = mapping.eventTypeId === 41 ? 'Kirche Kunterbunt' : inferredTitle;
return {
name: normalizedTitle,
description: '',
date: hasMultipleDates ? '' : parsed.isoDate,
time: parsed.time,
endTime: parsed.endTime,
dateMode: hasMultipleDates ? 'bulk' : 'date',
bulkDates: hasMultipleDates ? allDates.join(', ') : '',
category,
eventTypeId: mapping.eventTypeId,
event_place_id: mapping.event_place_id,
};
},
isValidDayMonth(day, month) {
const dd = Number(day);
const mm = Number(month);
return Number.isInteger(dd) && Number.isInteger(mm) && dd >= 1 && dd <= 31 && mm >= 1 && mm <= 12;
},
parseDateAndTime(rawText) {
const text = String(rawText || '');
const dateMatch = text.match(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/);
const shortDateMatch = text.match(/\b(\d{1,2})\.(\d{1,2})\.\b/);
// Akzeptiert "19:30 Uhr", "19.30 Uhr" und auch "19:30"/"19.30" ohne "Uhr".
const timeMatch = text.match(/\b(\d{1,2})[:.](\d{2})(?:\s*uhr)?\b/i);
const longDateMatches = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/g)];
const shortDateMatches = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\.(?!\d)(?!\s*uhr\b)/gi)];
// Zeit robust parsen:
// 1) Zeitspannen immer als Startzeit übernehmen: "17.00 - 20.00 Uhr" -> 17:00
// 2) Einzelzeiten: "19:30" / "19:30 Uhr" / "19.30 Uhr"
const rangeMatch =
text.match(/\b(?:von\s+)?(\d{1,2})[.:](\d{2})\s*-\s*\d{1,2}[.:]\d{2}\s*uhr\b/i);
const rangeEndMatch =
text.match(/\b(?:von\s+)?\d{1,2}[.:]\d{2}\s*-\s*(\d{1,2})[.:](\d{2})\s*uhr\b/i);
const colonTimeMatch = text.match(/\b(?:um\s+|von\s+)?(\d{1,2}):(\d{2})(?:\s*uhr)?\b/i);
// Punkt-Zeiten nur mit klarem Zeitkontext akzeptieren, damit 09.02. (Datum) nicht als Uhrzeit gilt.
const dotTimeWithContextMatch =
text.match(/\bum\s+(\d{1,2})\.(\d{2})\s*uhr\b/i) ||
text.match(/\bvon\s+(\d{1,2})\.(\d{2})\b/i) ||
text.match(/\b(\d{1,2})\.(\d{2})\s*uhr\b/i);
let isoDate = '';
let time = '';
if (dateMatch) {
const dd = String(dateMatch[1]).padStart(2, '0');
const mm = String(dateMatch[2]).padStart(2, '0');
const yyyy = dateMatch[3];
let endTime = '';
const firstLong = longDateMatches.find((m) => this.isValidDayMonth(m[1], m[2]));
if (firstLong) {
const dd = String(firstLong[1]).padStart(2, '0');
const mm = String(firstLong[2]).padStart(2, '0');
const yyyy = firstLong[3];
isoDate = `${yyyy}-${mm}-${dd}`;
} else if (shortDateMatch) {
} else {
const firstShort = shortDateMatches.find((m) => this.isValidDayMonth(m[1], m[2]));
if (firstShort) {
const nowYear = new Date().getFullYear();
const dd = String(shortDateMatch[1]).padStart(2, '0');
const mm = String(shortDateMatch[2]).padStart(2, '0');
const dd = String(firstShort[1]).padStart(2, '0');
const mm = String(firstShort[2]).padStart(2, '0');
isoDate = `${nowYear}-${mm}-${dd}`;
}
}
const timeMatch = rangeMatch || colonTimeMatch || dotTimeWithContextMatch;
if (timeMatch) {
const hh = String(timeMatch[1]).padStart(2, '0');
const min = String(timeMatch[2]).padStart(2, '0');
time = `${hh}:${min}`;
}
return { isoDate, time };
if (rangeEndMatch) {
const hhEnd = String(rangeEndMatch[1]).padStart(2, '0');
const minEnd = String(rangeEndMatch[2]).padStart(2, '0');
endTime = `${hhEnd}:${minEnd}`;
}
return { isoDate, time, endTime };
},
extractAllDates(rawText) {
const text = String(rawText || '');
const nowYear = new Date().getFullYear();
const longMatches = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/g)];
const shortMatches = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\.(?!\d)/g)];
const shortMatches = [...text.matchAll(/\b(\d{1,2})\.(\d{1,2})\.(?!\d)(?!\s*uhr\b)/gi)];
const normalized = longMatches.map((m) => {
const normalized = longMatches
.filter((m) => this.isValidDayMonth(m[1], m[2]))
.map((m) => {
const dd = String(m[1]).padStart(2, '0');
const mm = String(m[2]).padStart(2, '0');
const yyyy = m[3];
@@ -187,6 +264,7 @@ export default {
});
shortMatches.forEach((m) => {
if (!this.isValidDayMonth(m[1], m[2])) return;
const dd = String(m[1]).padStart(2, '0');
const mm = String(m[2]).padStart(2, '0');
const fallback = `${dd}.${mm}.${nowYear}`;
@@ -266,8 +344,17 @@ export default {
const text = String(rawText || '').trim();
const parts = text.split('|').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return 'Import aus Gemeindebrief';
// Bevorzugt den eigentlichen Titelteil aus dem Segment mit Datum/Uhrzeit.
const firstPart = parts[0] || '';
const fromFirstPart = firstPart
.replace(/^(?:so|mo|di|mi|do|fr|sa)\.,?\s*/i, '')
.replace(/^\d{1,2}\.\d{1,2}\.(?:\d{2,4})?\s*/i, '')
.replace(/^\d{1,2}[:.]\d{2}\s*(?:-\s*\d{1,2}[:.]\d{2}\s*)?uhr\s*/i, '')
.replace(/^\d{1,2}\.\d{1,2}\.\s*/i, '')
.trim();
const withoutDate = parts.find((p) => !/\d{1,2}\.\d{1,2}\./.test(p) && !/\d{1,2}[:.]\d{2}\s*uhr/i.test(p));
const cleaned = (withoutDate || parts[0] || '')
const baseTitle = fromFirstPart || withoutDate || parts[0] || '';
const cleaned = baseTitle
.replace(/\[\[FLAG_NEIGHBOR_INVITATION\]\]/gi, '')
.replace(/\[\[FLAG_SELF_INFORMATION\]\]/gi, '')
.replace(/bitte informieren sie sich auch auf den internetseiten.*$/i, '')
@@ -277,44 +364,18 @@ export default {
return cleaned || 'Import aus Gemeindebrief';
},
transferToEventForm(entry, category) {
const parsed = this.parseDateAndTime(entry);
const mapping = this.inferEventMapping(entry, category);
const allDates = this.extractAllDates(entry);
const hasMultipleDates = allDates.length > 1;
const draft = {
name: this.extractTitle(entry),
description: '',
date: hasMultipleDates ? '' : parsed.isoDate,
time: parsed.time,
dateMode: hasMultipleDates ? 'bulk' : 'single',
bulkDates: hasMultipleDates ? allDates.join(', ') : '',
category,
eventTypeId: mapping.eventTypeId,
event_place_id: mapping.event_place_id,
};
const draft = this.buildEventDraft(entry, category);
localStorage.setItem('newsletter_import_event_draft', JSON.stringify(draft));
this.$router.push('/admin/events');
},
transferSelectedToEventBulk() {
if (this.selectedEventEntries.length === 0) return;
const bulkDates = this.selectedEventEntries
.map((entry) => {
const parsed = this.parseDateAndTime(entry);
if (!parsed.isoDate) return '';
const [yyyy, mm, dd] = parsed.isoDate.split('-');
return `${dd}.${mm}.${yyyy}`;
})
.filter(Boolean)
.join(', ');
const first = this.selectedEventEntries[0] || '';
const draft = {
name: this.extractTitle(first),
description: '',
dateMode: 'bulk',
bulkDates,
...this.inferEventMapping(first, 'Regelmäßige Termine'),
};
localStorage.setItem('newsletter_import_event_draft', JSON.stringify(draft));
const queue = this.selectedEventEntries
.map((token) => this.decodeBulkSelection(token))
.filter((item) => item.entry)
.map((item) => this.buildEventDraft(item.entry, item.category));
if (queue.length === 0) return;
localStorage.setItem('newsletter_import_event_bulk_queue', JSON.stringify(queue));
this.$router.push('/admin/events');
},
handleNewsletterPdfSelect(event) {