// KI-JSON-Import + Backup-Export/Import (Phase 7, siehe anforderung.md // Abschnitt "Vorgabe für das KI-JSON"). Ein Modal mit zwei Bereichen // ("Tabs"): "KI-Ausflugsziele importieren" (Validierung/Vorschau/Übernahme // von KI-generierten Ausflugszielen über js/import/importValidator.js) und // "Backup sichern/wiederherstellen" (Export/Import des kompletten // Datenbestands über js/import/backup.js). Beides zusammen in einem Modal, // da beide Funktionen denselben Baustein "JSON einfügen ODER Datei // hochladen" benötigen und ein zweites, fast identisches Modal unnötig // redundant wäre. // // WICHTIG (Sicherheit): Diese Datei zeigt UNGEPRÜFTE FREMDDATEN an (rohe, // von einer KI erzeugte oder aus einer Datei geladene Objekte, BEVOR sie // über normalizePlace() übernommen wurden). Rendering deshalb ausschließlich // über textContent/DOM-APIs (el()), niemals innerHTML - siehe js/utils/dom.js. // // Aufbau/Muster analog zu js/ui/form.js und js/ui/promptGenerator.js (Modal // mit Backdrop, Escape/Backdrop-Klick schließt, Fokus-Management). // // API: EventMap.ui.initImport(containerId) initialisiert das Modal einmalig. // EventMap.ui.openImport() öffnet es (immer im zurückgesetzten Zustand, // startend auf dem KI-Import-Tab). (function () { const { el, clearChildren } = EventMap.utils; let container = null; let refs = null; // Ergebnis der letzten Validierung/des letzten Parse-Vorgangs, damit die // Bestätigungs-Buttons ohne erneutes Parsen darauf zugreifen können. let lastAiValidation = null; let lastBackupParse = null; function readFileAsText(file, callback) { const reader = new FileReader(); reader.onload = () => callback(String(reader.result || '')); reader.onerror = () => callback(null); reader.readAsText(file); } /** Baut den gemeinsamen Eingabe-Baustein "JSON einfügen ODER Datei hochladen". */ function buildJsonInputGroup({ idPrefix, textareaPlaceholder }) { const textarea = el('textarea', { className: 'import-input__textarea', attrs: { id: `${idPrefix}-textarea`, rows: '8', placeholder: textareaPlaceholder }, }); const fileInput = el('input', { className: 'import-input__file', attrs: { type: 'file', id: `${idPrefix}-file`, accept: '.json,application/json' }, on: { change: (event) => { const file = event.target.files && event.target.files[0]; if (!file) return; readFileAsText(file, (text) => { if (text !== null) { textarea.value = text; } }); }, }, }); const wrapper = el('div', { className: 'import-input' }, [ el('label', { className: 'form-field__label', attrs: { for: `${idPrefix}-textarea` } }, ['JSON einfügen']), textarea, el('label', { className: 'form-field__label', attrs: { for: `${idPrefix}-file` } }, [ '… oder Datei hochladen', ]), fileInput, ]); return { wrapper, textarea, fileInput }; } function resolveCategoryLabel(rawCategory) { const { CATEGORIES, getCategoryById, getCategoryByLabel } = EventMap.config; if (typeof rawCategory === 'string' && CATEGORIES.some((cat) => cat.id === rawCategory)) { return getCategoryById(rawCategory).label; } return getCategoryByLabel(rawCategory).label; } function buildAiValidItem(entry, index, existingPlaces) { const hasName = typeof entry.raw.name === 'string' && entry.raw.name.trim() !== ''; const name = hasName ? entry.raw.name.trim() : `Eintrag ${index + 1}`; const categoryLabel = resolveCategoryLabel(entry.raw.category); const duplicate = EventMap.import.findDuplicate(entry.raw, existingPlaces); const detailNodes = []; if (duplicate) { detailNodes.push( el('p', { className: 'import-preview__duplicate-note', text: `Mögliches Duplikat des bestehenden Eintrags "${duplicate.name}" - wird bei aktivem Duplikat-Filter übersprungen.`, }) ); } if (entry.warnings.length > 0) { detailNodes.push( el( 'ul', { className: 'import-preview__warnings' }, entry.warnings.map((warning) => el('li', { text: warning })) ) ); } return el('li', { className: 'import-preview__item import-preview__item--valid' }, [ el('div', { className: 'import-preview__item-header' }, [ el('span', { className: 'import-preview__name', text: name }), el('span', { className: 'import-preview__category', text: categoryLabel }), ]), ...detailNodes, ]); } function buildAiRejectedItem(entry, index) { const hasName = entry.raw && typeof entry.raw === 'object' && typeof entry.raw.name === 'string' && entry.raw.name.trim() !== ''; const name = hasName ? entry.raw.name.trim() : `Eintrag ${index + 1}`; return el('li', { className: 'import-preview__item import-preview__item--rejected' }, [ el('div', { className: 'import-preview__item-header' }, [ el('span', { className: 'import-preview__name', text: name }), el('span', { className: 'import-preview__badge', text: 'Abgelehnt' }), ]), el( 'ul', { className: 'import-preview__reasons' }, entry.reasons.map((reason) => el('li', { text: reason })) ), ]); } function showAiParseError(message) { refs.aiParseError.textContent = message; refs.aiParseError.hidden = false; } function showBackupParseError(message) { refs.backupParseError.textContent = message; refs.backupParseError.hidden = false; } function computeAiImportableCount() { if (!lastAiValidation) return 0; const existingPlaces = EventMap.store.getState().places; const skipDuplicates = refs.aiSkipDuplicatesCheckbox.checked; return lastAiValidation.validPlaces.filter((entry) => { if (!skipDuplicates) return true; return !EventMap.import.findDuplicate(entry.raw, existingPlaces); }).length; } function updateAiImportButton() { const count = computeAiImportableCount(); refs.aiImportBtn.textContent = `${count} Ausflugsziel${count === 1 ? '' : 'e'} importieren`; refs.aiImportBtn.disabled = count === 0; } function renderAiPreview(result) { const { warnings, validPlaces, rejectedPlaces } = result; const withWarningsCount = validPlaces.filter((entry) => entry.warnings.length > 0).length; refs.aiSummary.textContent = `${validPlaces.length} gültig (davon ${withWarningsCount} mit Warnung), ${rejectedPlaces.length} abgelehnt.`; clearChildren(refs.aiGlobalWarnings); warnings.forEach((warning) => refs.aiGlobalWarnings.appendChild(el('li', { text: warning }))); refs.aiGlobalWarnings.hidden = warnings.length === 0; const existingPlaces = EventMap.store.getState().places; clearChildren(refs.aiEntriesList); validPlaces.forEach((entry, index) => { refs.aiEntriesList.appendChild(buildAiValidItem(entry, index, existingPlaces)); }); rejectedPlaces.forEach((entry, index) => { refs.aiEntriesList.appendChild(buildAiRejectedItem(entry, index)); }); updateAiImportButton(); refs.aiPreview.hidden = false; } function handleAiValidateClick() { refs.aiParseError.hidden = true; refs.aiParseError.textContent = ''; refs.aiPreview.hidden = true; refs.aiStatus.textContent = ''; lastAiValidation = null; const text = refs.aiTextarea.value.trim(); if (!text) { showAiParseError('Bitte JSON einfügen oder eine Datei hochladen.'); return; } const result = EventMap.import.parseAndValidate(text); if (!result.success) { showAiParseError(result.error); return; } lastAiValidation = result; renderAiPreview(result); } function handleAiImportClick() { if (!lastAiValidation) return; const state = EventMap.store.getState(); const skipDuplicates = refs.aiSkipDuplicatesCheckbox.checked; const toImport = []; for (const entry of lastAiValidation.validPlaces) { if (skipDuplicates && EventMap.import.findDuplicate(entry.raw, state.places)) { continue; } toImport.push(EventMap.models.normalizePlace(entry.raw)); } if (toImport.length === 0) { refs.aiStatus.textContent = 'Keine Ausflugsziele übernommen (alle gültigen Einträge wurden als Duplikat übersprungen).'; return; } EventMap.store.setState({ places: [...state.places, ...toImport] }); EventMap.store.persistPlaces(); refs.aiStatus.textContent = `${toImport.length} Ausflugsziel${toImport.length === 1 ? '' : 'e'} erfolgreich importiert.`; refs.aiPreview.hidden = true; refs.aiTextarea.value = ''; refs.aiFileInput.value = ''; lastAiValidation = null; } function handleBackupCheckClick() { refs.backupParseError.hidden = true; refs.backupParseError.textContent = ''; refs.backupPreview.hidden = true; refs.backupStatus.textContent = ''; lastBackupParse = null; const text = refs.backupTextarea.value.trim(); if (!text) { showBackupParseError('Bitte JSON einfügen oder eine Backup-Datei hochladen.'); return; } const result = EventMap.import.parseBackup(text); if (!result.success) { showBackupParseError(result.error); return; } lastBackupParse = result; const existingCount = EventMap.store.getState().places.length; refs.backupSummary.textContent = `Backup enthält ${result.places.length} Ausflugsziel(e). Aktuell gespeichert: ${existingCount}.`; refs.backupPreview.hidden = false; } function finishBackupImport(message) { refs.backupStatus.textContent = message; refs.backupPreview.hidden = true; refs.backupTextarea.value = ''; refs.backupFileInput.value = ''; lastBackupParse = null; } function handleBackupMergeClick() { if (!lastBackupParse) return; const state = EventMap.store.getState(); const merged = EventMap.import.mergeBackupPlaces(state.places, lastBackupParse.places); EventMap.store.setState({ places: merged }); EventMap.store.persistPlaces(); finishBackupImport(`${lastBackupParse.places.length} Ausflugsziel(e) ergänzt. Gesamtbestand: ${merged.length}.`); } function handleBackupReplaceClick() { if (!lastBackupParse) return; const existingCount = EventMap.store.getState().places.length; const confirmed = window.confirm( `Wirklich alle ${existingCount} bestehenden Ausflugsziele durch die ${lastBackupParse.places.length} ` + 'Ausflugsziele aus dem Backup ersetzen? Dies kann nicht rückgängig gemacht werden.' ); if (!confirmed) return; EventMap.store.setState({ places: lastBackupParse.places }); EventMap.store.persistPlaces(); finishBackupImport(`${lastBackupParse.places.length} Ausflugsziel(e) übernommen (bestehende Daten ersetzt).`); } function setActiveTab(tab) { const isAi = tab === 'ai'; refs.aiPanel.hidden = !isAi; refs.backupPanel.hidden = isAi; refs.tabAiBtn.setAttribute('aria-selected', String(isAi)); refs.tabBackupBtn.setAttribute('aria-selected', String(!isAi)); refs.tabAiBtn.classList.toggle('import-modal__tab--active', isAi); refs.tabBackupBtn.classList.toggle('import-modal__tab--active', !isAi); } function buildSkeleton(rootContainer) { refs = {}; refs.backdrop = el('div', { className: 'import-modal__backdrop', on: { click: () => closeImport() }, }); refs.closeBtn = el( 'button', { className: 'import-modal__close', attrs: { type: 'button', 'aria-label': 'Import-Fenster schließen' }, on: { click: () => closeImport() }, }, ['✕'] ); refs.title = el('h2', { className: 'import-modal__title', attrs: { id: 'import-modal-title' }, text: 'Ausflugsziele importieren', }); refs.tabAiBtn = el( 'button', { className: 'import-modal__tab', attrs: { type: 'button', role: 'tab', id: 'import-tab-ai', 'aria-controls': 'import-panel-ai' }, on: { click: () => setActiveTab('ai') }, }, ['KI-Ausflugsziele importieren'] ); refs.tabBackupBtn = el( 'button', { className: 'import-modal__tab', attrs: { type: 'button', role: 'tab', id: 'import-tab-backup', 'aria-controls': 'import-panel-backup' }, on: { click: () => setActiveTab('backup') }, }, ['Backup sichern/wiederherstellen'] ); refs.tabList = el( 'div', { className: 'import-modal__tabs', attrs: { role: 'tablist', 'aria-label': 'Import-Bereich wählen' } }, [refs.tabAiBtn, refs.tabBackupBtn] ); // --- KI-Import-Tab --- const aiInput = buildJsonInputGroup({ idPrefix: 'import-ai', textareaPlaceholder: 'Hier das von der KI erzeugte JSON einfügen …', }); refs.aiTextarea = aiInput.textarea; refs.aiFileInput = aiInput.fileInput; refs.aiParseError = el('p', { className: 'import-error', attrs: { role: 'alert' } }); refs.aiParseError.hidden = true; refs.aiValidateBtn = el( 'button', { className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleAiValidateClick } }, ['Prüfen'] ); refs.aiSummary = el('p', { className: 'import-preview__summary' }); refs.aiGlobalWarnings = el('ul', { className: 'import-preview__global-warnings' }); refs.aiEntriesList = el('ul', { className: 'import-preview__list' }); refs.aiSkipDuplicatesCheckbox = el('input', { attrs: { type: 'checkbox', id: 'import-ai-skip-duplicates' }, on: { change: updateAiImportButton }, }); refs.aiSkipDuplicatesCheckbox.checked = true; refs.aiImportBtn = el( 'button', { className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleAiImportClick } }, ['0 Ausflugsziele importieren'] ); refs.aiStatus = el('p', { className: 'import-status', attrs: { role: 'status' } }); refs.aiPreview = el('div', { className: 'import-preview' }, [ refs.aiSummary, refs.aiGlobalWarnings, refs.aiEntriesList, el('label', { className: 'form-checkbox import-preview__skip-duplicates' }, [ refs.aiSkipDuplicatesCheckbox, 'Erkannte Duplikate (Name + Standort stimmen mit einem bestehenden Eintrag überein) überspringen', ]), refs.aiImportBtn, ]); refs.aiPreview.hidden = true; refs.aiPanel = el( 'div', { className: 'import-panel', attrs: { id: 'import-panel-ai', role: 'tabpanel', 'aria-labelledby': 'import-tab-ai' } }, [ el('p', { className: 'import-panel__intro', text: 'Füge das JSON aus der KI-Antwort ein oder lade eine gespeicherte Datei hoch. Jeder Eintrag wird ' + 'einzeln geprüft, bevor er übernommen wird - ungültige Einträge werden abgelehnt und angezeigt.', }), aiInput.wrapper, refs.aiParseError, refs.aiValidateBtn, refs.aiPreview, refs.aiStatus, ] ); // --- Backup-Tab --- refs.backupExportBtn = el( 'button', { className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: () => EventMap.import.exportBackup() } }, ['💾 Backup exportieren'] ); const backupInput = buildJsonInputGroup({ idPrefix: 'import-backup', textareaPlaceholder: 'Hier den Inhalt einer eventmap-backup-*.json-Datei einfügen …', }); refs.backupTextarea = backupInput.textarea; refs.backupFileInput = backupInput.fileInput; refs.backupParseError = el('p', { className: 'import-error', attrs: { role: 'alert' } }); refs.backupParseError.hidden = true; refs.backupCheckBtn = el( 'button', { className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleBackupCheckClick } }, ['Backup prüfen'] ); refs.backupSummary = el('p', { className: 'import-preview__summary' }); refs.backupMergeBtn = el( 'button', { className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleBackupMergeClick } }, ['Bestehende Daten ergänzen'] ); refs.backupReplaceBtn = el( 'button', { className: 'btn btn--danger', attrs: { type: 'button' }, on: { click: handleBackupReplaceClick } }, ['Bestehende Daten ersetzen'] ); refs.backupStatus = el('p', { className: 'import-status', attrs: { role: 'status' } }); refs.backupPreview = el('div', { className: 'import-preview' }, [ refs.backupSummary, el('div', { className: 'import-preview__actions' }, [refs.backupMergeBtn, refs.backupReplaceBtn]), ]); refs.backupPreview.hidden = true; refs.backupPanel = el( 'div', { className: 'import-panel', attrs: { id: 'import-panel-backup', role: 'tabpanel', 'aria-labelledby': 'import-tab-backup' } }, [ el('section', { className: 'import-panel__section' }, [ el('h3', { className: 'place-form__section-label', text: 'Backup exportieren' }), el('p', { className: 'import-panel__intro', text: 'Speichert den gesamten aktuellen Datenbestand als JSON-Datei, z.B. zur Sicherung oder zur Übertragung auf ein anderes Gerät.', }), refs.backupExportBtn, ]), el('section', { className: 'import-panel__section' }, [ el('h3', { className: 'place-form__section-label', text: 'Backup wiederherstellen' }), el('p', { className: 'import-panel__intro', text: 'Fügt ein zuvor exportiertes Backup ein oder lädt es hoch und wählt danach, ob die bestehenden Daten ersetzt oder ergänzt werden sollen.', }), backupInput.wrapper, refs.backupParseError, refs.backupCheckBtn, refs.backupPreview, refs.backupStatus, ]), ] ); refs.panel = el( 'div', { className: 'import-modal__panel', attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'import-modal-title' }, }, [refs.closeBtn, refs.title, refs.tabList, refs.aiPanel, refs.backupPanel] ); rootContainer.appendChild(refs.backdrop); rootContainer.appendChild(refs.panel); } function resetModalState() { lastAiValidation = null; lastBackupParse = null; refs.aiTextarea.value = ''; refs.aiFileInput.value = ''; refs.aiParseError.hidden = true; refs.aiPreview.hidden = true; refs.aiStatus.textContent = ''; refs.aiSkipDuplicatesCheckbox.checked = true; refs.backupTextarea.value = ''; refs.backupFileInput.value = ''; refs.backupParseError.hidden = true; refs.backupPreview.hidden = true; refs.backupStatus.textContent = ''; setActiveTab('ai'); } /** Öffnet das Import-/Backup-Fenster, immer im zurückgesetzten Zustand auf dem KI-Import-Tab. */ function openImport() { if (!container || !refs) { console.error('EventMap: Import-Fenster wurde noch nicht initialisiert (initImport()).'); return; } resetModalState(); container.hidden = false; document.body.classList.add('import-modal-open'); refs.aiTextarea.focus(); } function closeImport() { if (!container || container.hidden) return; container.hidden = true; document.body.classList.remove('import-modal-open'); } /** Initialisiert das Import-/Backup-Fenster im Element mit der übergebenen id. */ function initImport(containerId) { const containerEl = document.getElementById(containerId); if (!containerEl) { console.error(`EventMap: Import-Container #${containerId} nicht gefunden.`); return; } container = containerEl; clearChildren(container); buildSkeleton(container); setActiveTab('ai'); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !container.hidden) { closeImport(); } }); } EventMap.ui.initImport = initImport; EventMap.ui.openImport = openImport; })();