Files
miriamgemeinde/src/content/admin/NewsletterImportManagement.vue

435 lines
19 KiB
Vue

<template>
<div class="newsletter-import">
<h2>Gemeindebrief-Import (PDF)</h2>
<div class="import-section">
<p class="hint">
Diese Seite parst den Gemeindebrief und zeigt eine Vorschau nach Kategorien.
Die finale Übernahme bauen wir auf Basis der Parser-Ergebnisse schrittweise aus.
</p>
<div class="import-content">
<label for="newsletter-import-file">Datei auswählen (.pdf):</label>
<input
id="newsletter-import-file"
ref="newsletterFileInput"
type="file"
accept=".pdf"
@change="handleNewsletterPdfSelect"
/>
<div v-if="newsletterPdfFile" class="selected-file">
Ausgewählte PDF: {{ newsletterPdfFile.name }}
</div>
<button
type="button"
class="submit-import-button"
:disabled="!newsletterPdfFile || isNewsletterImporting"
@click="importNewsletterPdf"
>
{{ isNewsletterImporting ? 'Parse PDF...' : 'PDF parsen' }}
</button>
</div>
</div>
<div v-if="newsletterImportResult" class="newsletter-preview">
<h3>Vorschau</h3>
<p class="newsletter-meta">
Seiten: {{ newsletterImportResult.meta?.pages || '-' }}, Zeilen: {{ newsletterImportResult.meta?.lineCount || '-' }}
</p>
<ul class="newsletter-counts">
<li>Gottesdienste: derzeit deaktiviert</li>
<li>Regelmäßige Termine: {{ newsletterImportResult.parsed?.regelmaessigeTermine?.length || 0 }}</li>
<li>Besondere Gottesdienste: {{ newsletterImportResult.parsed?.besondereGottesdienste?.length || 0 }}</li>
<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">
<h4>Gefundene Einträge (Details)</h4>
<div class="bulk-actions">
<button type="button" class="submit-import-button" :disabled="selectedEventEntries.length === 0" @click="transferSelectedToEventBulk">
Auswahl ({{ selectedEventEntries.length }}) als Event-Bulk übernehmen
</button>
</div>
<div class="detail-group">
<h5>Gottesdienste</h5>
<p>Der Gottesdienst-Import ist derzeit deaktiviert und wird aktuell nicht zur Übernahme angeboten.</p>
</div>
<div class="detail-group">
<h5>Regelmäßige Termine</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.regelmaessigeTermine || []" :key="`r-${idx}`">
<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>
</ul>
</div>
<div class="detail-group">
<h5>Besondere Gottesdienste</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.besondereGottesdienste || []" :key="`b-${idx}`">
<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>
</ul>
</div>
<div class="detail-group">
<h5>Miriamtreff</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.miriamtreff || []" :key="`m-${idx}`">
<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>
</ul>
</div>
<div class="detail-group">
<h5>Kinder und Jugend</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.kinderUndJugend || []" :key="`k-${idx}`">
<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>
</ul>
</div>
<div class="detail-group">
<h5>Frauenfrühstück</h5>
<ul>
<li v-for="(entry, idx) in newsletterImportResult.details.frauenfruehstueck || []" :key="`f-${idx}`">
<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">
<h4>Offene Fragen</h4>
<ul>
<li v-for="(question, idx) in newsletterImportResult.questions" :key="idx">{{ question }}</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import axios from '../../axios';
export default {
name: 'NewsletterImportManagement',
data() {
return {
newsletterPdfFile: null,
isNewsletterImporting: false,
newsletterImportResult: null,
selectedEventEntries: [],
};
},
created() {
const cached = localStorage.getItem('newsletter_import_last_result');
if (cached) {
try {
this.newsletterImportResult = JSON.parse(cached);
} catch (error) {
console.error('Konnte zwischengespeicherte Vorschau nicht lesen:', error);
}
}
},
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 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 = '';
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 {
const firstShort = shortDateMatches.find((m) => this.isValidDayMonth(m[1], m[2]));
if (firstShort) {
const nowYear = new Date().getFullYear();
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}`;
}
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)(?!\s*uhr\b)/gi)];
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];
return `${dd}.${mm}.${yyyy}`;
});
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}`;
if (!normalized.some((d) => d.startsWith(`${dd}.${mm}.`))) {
normalized.push(fallback);
}
});
return [...new Set(normalized)];
},
inferEventMapping(rawText, category) {
const text = String(rawText || '');
const normalized = text.toLowerCase();
let eventTypeId = 38; // Sonstiges
if (/miriamtreff/.test(normalized)) eventTypeId = 16;
else if (/frauenfr[üu]hst[üu]ck/.test(normalized)) eventTypeId = 25;
else if (/m[aä]nnerpalaver/.test(normalized)) eventTypeId = 15;
else if (/kinderkirche/.test(normalized)) eventTypeId = 4;
else if (/kinder kirche/.test(normalized)) eventTypeId = 4;
else if (/kigosabo|kindergottesdienst/.test(normalized)) eventTypeId = 5;
else if (/jungschar/.test(normalized)) eventTypeId = 6;
else if (/konfirmationsunterricht/.test(normalized)) eventTypeId = 3;
else if (/vorkonfirmandenkurs|vorkonfis/.test(normalized)) eventTypeId = 39;
else if (/vocal ensemble/.test(normalized)) eventTypeId = 17;
else if (/konzert/.test(normalized)) eventTypeId = 40;
else if (/vortrag/.test(normalized)) eventTypeId = 42;
else if (/weihnachtsmarkt/.test(normalized)) eventTypeId = 43;
else if (/kirchekunterbunt|kirche kunterbunt/.test(normalized)) eventTypeId = 41;
else if (category === 'Besondere Gottesdienste') eventTypeId = 1;
let eventPlaceId = null;
let eventPlaceName = '';
if (/gemeindehaus bonames/.test(normalized)) {
eventPlaceId = 7;
eventPlaceName = 'Gemeindehaus Bonames';
} else if (/gemeindehaus kalbach|crutzenhof|kalbach/.test(normalized)) {
eventPlaceId = 2;
eventPlaceName = 'Crutzenhof';
} else if (/gemeindehaus harheim|gemeindesaal harheim/.test(normalized)) {
eventPlaceId = 27;
eventPlaceName = 'Gemeindesaal Harheim';
} else if (/gemeindehaus nieder-?erlenbach|nieder-erlenbach/.test(normalized)) {
eventPlaceId = 13;
eventPlaceName = 'Nieder-Erlenbach';
} else if (/gemeindehaus nieder-?eschbach|nieder-eschbach/.test(normalized)) {
eventPlaceId = 14;
eventPlaceName = 'Nieder-Eschbach';
} else if (/gemeindehaus am b[üu]gel|am b[üu]gel/.test(normalized)) {
eventPlaceId = 12;
eventPlaceName = 'Am Bügel';
} else if (/gemeindehaus/.test(normalized) && /bonames/.test(normalized)) {
eventPlaceId = 7;
eventPlaceName = 'Gemeindehaus Bonames';
} else if (/jugendkeller bonames/.test(normalized)) {
eventPlaceId = 8;
eventPlaceName = 'Jugendkeller Bonames';
} else if (/kita sternenzelt/.test(normalized)) {
eventPlaceId = 6;
eventPlaceName = 'Kita Sternenzelt';
} else if (/bonames/.test(normalized)) {
eventPlaceId = 1;
eventPlaceName = 'Bonames';
} else if (/harheim/.test(normalized)) {
eventPlaceId = 15;
eventPlaceName = 'Harheim';
} else if (/im sauern/.test(normalized)) {
eventPlaceId = 28;
eventPlaceName = 'Im Sauern';
} else if (/wunderkiste/.test(normalized)) {
eventPlaceId = 16;
eventPlaceName = 'Miriams Wunderkiste';
}
return { eventTypeId, event_place_id: eventPlaceId, event_place_name: eventPlaceName };
},
extractTitle(rawText) {
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 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, '')
.replace(/einladung zum gottesdienst im nachbarschaftsraum/gi, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned || 'Import aus Gemeindebrief';
},
transferToEventForm(entry, category) {
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 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) {
const file = event.target.files?.[0];
if (!file) {
this.newsletterPdfFile = null;
return;
}
if (!file.name.toLowerCase().endsWith('.pdf')) {
alert('Bitte eine PDF-Datei auswählen.');
event.target.value = '';
this.newsletterPdfFile = null;
return;
}
this.newsletterPdfFile = file;
},
async importNewsletterPdf() {
if (!this.newsletterPdfFile) return;
this.isNewsletterImporting = true;
const formData = new FormData();
formData.append('file', this.newsletterPdfFile);
try {
const response = await axios.post('/worships/import/newsletter-pdf', formData);
this.newsletterImportResult = response.data;
this.selectedEventEntries = [];
localStorage.setItem('newsletter_import_last_result', JSON.stringify(response.data));
} catch (error) {
const msg = error.response?.data?.message || 'Fehler beim Parsen der PDF-Datei.';
alert(`Fehler: ${msg}`);
} finally {
this.isNewsletterImporting = false;
}
},
},
};
</script>
<style scoped>
.newsletter-import { max-width: 900px; margin: 0 auto; }
.hint { color: #555; margin-bottom: 10px; }
.import-section { border: 2px solid #ddd; border-radius: 8px; padding: 15px; background: #f9f9f9; }
.import-content { display: flex; flex-direction: column; gap: 10px; }
.submit-import-button { width: fit-content; }
.selected-file { padding: 8px; background: #e8f5e9; border: 1px solid #4caf50; border-radius: 4px; }
.newsletter-preview { margin-top: 16px; padding: 12px; border: 1px solid #ddd; border-radius: 6px; background: #fff; }
.newsletter-meta { color: #666; margin: 0 0 8px; }
.newsletter-counts { padding-left: 20px; }
.questions { margin-top: 10px; }
.details { margin-top: 12px; }
.detail-group { margin-bottom: 10px; }
.detail-group h5 { margin: 0 0 4px; }
.detail-group li { display: flex; gap: 8px; align-items: flex-start; margin-bottom: 6px; }
.transfer-button { white-space: nowrap; }
.bulk-actions { margin-bottom: 10px; }
</style>