From 39321d680f6dbd2d871ae8558f48775fa92ac576 Mon Sep 17 00:00:00 2001 From: Hermann Date: Fri, 24 Jul 2026 12:50:05 +0200 Subject: [PATCH] Phase 7: KI-JSON-Import und Backup-Export/Import Neues Modul js/import/ mit reiner Validierungslogik (importValidator.js: parseAndValidate(), findDuplicate()). Jeder Ausflugsziel-Eintrag im importierten KI-JSON wird einzeln bewertet statt alles-oder-nichts: fehlender Name/Kategorie oder ungueltige Koordinaten fuehren zur Ablehnung, weiche Probleme (unbekannte Kategorie, unsichere website/image-URL, ungueltige Bewertung, falscher Feldtyp) erzeugen nur eine Warnung. sanitizeEntry() setzt sicherheitskritische Felder vor der Uebernahme zurueck (z.B. javascript:-URLs), da normalizePlace() selbst keine Sicherheitspruefung vornimmt. findDuplicate() erkennt wahrscheinliche Duplikate gegen den bestehenden Datenbestand. UI (js/ui/import.js): Modal mit zwei Tabs - KI-Ausflugsziele importieren (Text einfuegen oder Datei hochladen, Vorschau mit gueltig/Warnung/abgelehnt, Duplikat-Toggle, Bestaetigen) und Backup sichern/wiederherstellen (js/import/backup.js: Export als JSON-Datei, Wiederherstellen mit Ersetzen/Ergaenzen-Wahl). Vorschau-Rendering ausschliesslich ueber el()/textContent, da es sich um echte, ungepruefte Fremddaten handelt. Damit sind alle Kernfunktionen aus anforderung.md umgesetzt. docs/plan.md entsprechend fortgeschrieben. --- css/import.css | 267 ++++++++++++++++ docs/plan.md | 33 +- index.html | 13 + js/import/backup.js | 66 ++++ js/import/importValidator.js | 271 +++++++++++++++++ js/main.js | 12 + js/ui/import.js | 568 +++++++++++++++++++++++++++++++++++ js/ui/promptGenerator.js | 11 +- js/utils/dom.js | 1 + 9 files changed, 1236 insertions(+), 6 deletions(-) create mode 100644 css/import.css create mode 100644 js/import/backup.js create mode 100644 js/import/importValidator.js create mode 100644 js/ui/import.js diff --git a/css/import.css b/css/import.css new file mode 100644 index 0000000..d2be4fb --- /dev/null +++ b/css/import.css @@ -0,0 +1,267 @@ +/* KI-JSON-Import + Backup-Export/Import (Phase 7), Modal mit zwei Tabs. + Struktur wird von js/ui/import.js in #import-modal eingehängt. Grundgerüst + analog zu css/promptGenerator.css (Vollbild auf Mobile, zentriertes + breites Panel ab 1024px). Formularbausteine (.form-field__label, + .form-checkbox, .place-form__section-label) nutzen bewusst dieselben + Klassen wie das Ausflugsziel-Formular statt sie zu duplizieren. */ + +.import-modal { + position: fixed; + inset: 0; + z-index: 2400; +} + +.import-modal[hidden] { + display: none; +} + +.import-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.import-modal__panel { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--color-surface); + overflow-y: auto; + padding: var(--space-lg); + padding-top: calc(var(--space-lg) + 36px); + gap: var(--space-md); +} + +.import-modal__close { + position: absolute; + top: var(--space-sm); + right: var(--space-sm); + z-index: 1; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.import-modal__title { + font-size: 1.3rem; + font-weight: 700; +} + +.import-modal__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-sm); +} + +.import-modal__tab { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-md); + background: var(--color-background); + color: var(--color-text); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.import-modal__tab--active { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} + +.import-panel { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.import-panel[hidden] { + display: none; +} + +.import-panel__intro { + color: var(--color-text-muted); + line-height: 1.4; +} + +.import-panel__section { + display: flex; + flex-direction: column; + gap: var(--space-sm); + border-top: 1px solid var(--color-border); + padding-top: var(--space-md); +} + +.import-panel__section:first-of-type { + border-top: none; + padding-top: 0; +} + +.import-input { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.import-input__textarea { + width: 100%; + min-height: 160px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8rem; + line-height: 1.4; + background: var(--color-background); + color: var(--color-text); + resize: vertical; +} + +.import-input__file { + font-size: 0.85rem; +} + +.import-error { + color: var(--color-danger); + font-size: 0.85rem; +} + +.import-error[hidden] { + display: none; +} + +.import-status { + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.import-preview { + display: flex; + flex-direction: column; + gap: var(--space-sm); + border-top: 1px solid var(--color-border); + padding-top: var(--space-md); +} + +.import-preview[hidden] { + display: none; +} + +.import-preview__summary { + font-weight: 700; +} + +.import-preview__global-warnings { + margin: 0; + padding-left: 1.25em; + color: var(--color-danger); + font-size: 0.85rem; +} + +.import-preview__global-warnings[hidden] { + display: none; +} + +.import-preview__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); + max-height: 320px; + overflow-y: auto; +} + +.import-preview__item { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.import-preview__item--rejected { + border-color: var(--color-danger); + background: #fdecea; +} + +.import-preview__item-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); +} + +.import-preview__name { + font-weight: 700; +} + +.import-preview__category { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.import-preview__badge { + font-size: 0.75rem; + font-weight: 700; + color: var(--color-danger); + text-transform: uppercase; +} + +.import-preview__duplicate-note { + font-size: 0.8rem; + color: var(--color-text-muted); + margin: 0; +} + +.import-preview__warnings, +.import-preview__reasons { + margin: 0; + padding-left: 1.25em; + font-size: 0.8rem; +} + +.import-preview__warnings { + color: var(--color-text-muted); +} + +.import-preview__reasons { + color: var(--color-danger); +} + +.import-preview__skip-duplicates { + font-size: 0.85rem; +} + +.import-preview__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} + +@media (min-width: 1024px) { + .import-modal__panel { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(840px, 95vw); + height: min(90vh, 900px); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + } +} diff --git a/docs/plan.md b/docs/plan.md index 9dcf6b0..d55dc0d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -161,7 +161,38 @@ Seed-Daten dadurch synchron direkt aus dem globalen Namespace, ganz ohne | 4 | Filter- und Suchfunktion (Kategorie, Entfernung, Alter, Kosten, Indoor/Outdoor, Ausstattung, Bewertung, Region) inkl. Anwendung auf Karte + Liste | ✅ umgesetzt | | 5 | Route/Navigation-Integration | ✅ bereits in Phase 2 erledigt (OSM-Directions-Button in der Detailansicht) | | 6 | KI-Prompt-Generator (Eingabeformular, Promptgenerierung, Copy/Download) | ✅ umgesetzt | -| 7 | KI-JSON-Import (Validierung, Vorschau, Übernahme ins LocalStorage) + Backup-Export/Import + Politur | offen | +| 7 | KI-JSON-Import (Validierung, Vorschau, Übernahme ins LocalStorage) + Backup-Export/Import | ✅ umgesetzt | + +Damit sind alle in `anforderung.md` geforderten Kernfunktionen umgesetzt. +Offen bleibt optionale Politur (Empty-States, A11y-Feinschliff, +Adressgeocoding) — kein Muss laut Anforderung, bei Bedarf als eigene +Folgephase. + +## KI-JSON-Import + Backup (Phase 7) + +- `js/import/importValidator.js`: reine Funktion `parseAndValidate(jsonText)` + bewertet jeden Ausflugsziel-Eintrag im importierten KI-JSON EINZELN + (kein Alles-oder-nichts). Pflicht für Übernahme: `name`, `category`, + gültige Koordinaten — sonst Ablehnung mit Begründung. Weiche Probleme + (unbekannte Kategorie, unbekannter Kosten-/Umgebungstyp, ungültige + Bewertung, unsichere `website`/`image`-URL) führen zu einer Warnung, + nicht zur Ablehnung; `sanitizeEntry()` setzt diese Felder vor der + Übernahme sicherheitshalber zurück (z.B. `javascript:`-URLs → leerer + String), BEVOR `normalizePlace()` sie verarbeitet, da dieses selbst + keine Sicherheitsprüfung vornimmt. `findDuplicate()` erkennt + wahrscheinliche Duplikate (Name + Koordinaten < 0.3 km) gegen den + bestehenden Datenbestand. +- `js/ui/import.js`: Modal mit zwei Tabs. Tab 1 "KI-Ausflugsziele + importieren": Text einfügen ODER Datei hochladen (`FileReader`) → + Prüfen → Vorschau (gültig/Warnung/abgelehnt, Duplikat-Hinweise, globaler + "Duplikate überspringen"-Toggle) → Bestätigen. Tab 2 "Backup + sichern/wiederherstellen" (`js/import/backup.js`): Export als + JSON-Datei (`downloadTextFile`), Wiederherstellen mit Wahl + "Ersetzen" (mit `confirm()`-Sicherheitsabfrage) oder "Ergänzen" + (Upsert nach `id`). +- Einstiegspunkt: Toolbar-Button "📥 KI-Ausflugsziele importieren". +- Rendering der Vorschau ausschließlich über `el()`/`textContent`, da es + sich um echte, ungeprüfte Fremddaten handelt. ## KI-Prompt-Generator (Phase 6) diff --git a/index.html b/index.html index 4c6ecd4..d209887 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,7 @@ + @@ -53,6 +54,9 @@ +

Karte wird geladen…

@@ -73,6 +77,7 @@ + @@ -110,6 +115,14 @@ + + + + + diff --git a/js/import/backup.js b/js/import/backup.js new file mode 100644 index 0000000..270396a --- /dev/null +++ b/js/import/backup.js @@ -0,0 +1,66 @@ +// Backup-Export/Import des Gesamtdatenbestands (Phase 7). Eigenständige, +// bewusst einfachere Funktion als der KI-JSON-Import (js/import/ +// importValidator.js): Backup-Daten stammen aus der eigenen Anwendung, nicht +// von einer externen KI, daher ist kein Vorschau-/Validierungs-Workflow pro +// Eintrag nötig - lediglich die Grundstruktur ({places: [...]}) wird +// geprüft, jeder Eintrag läuft danach durch normalizePlace() (übernimmt +// dabei vorhandene id/createdAt/updatedAt aus dem Backup unverändert). + +(function () { + const { getState } = EventMap.store; + const { normalizePlace } = EventMap.models; + const { downloadTextFile } = EventMap.utils; + + /** Exportiert state.places als herunterladbare JSON-Backup-Datei. */ + function exportBackup() { + const { places } = getState(); + const now = new Date(); + + const data = { + version: 1, + exportedAt: now.toISOString(), + places, + }; + + const dateStamp = now.toISOString().slice(0, 10); + downloadTextFile(`eventmap-backup-${dateStamp}.json`, JSON.stringify(data, null, 2)); + } + + /** + * Parst den Text einer Backup-Datei. + * @param {string} jsonText + * @returns {{success: boolean, error: string|null, places: Array}} + */ + function parseBackup(jsonText) { + let data; + try { + data = JSON.parse(jsonText); + } catch (err) { + return { success: false, error: 'Ungültiges JSON: ' + err.message, places: [] }; + } + + if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.places)) { + return { success: false, error: 'Das Backup muss ein Objekt mit einem "places"-Array sein.', places: [] }; + } + + const places = data.places.map((raw) => normalizePlace(raw)); + return { success: true, error: null, places }; + } + + /** + * Führt bestehende und importierte Places zusammen ("Ergänzen"): bei + * gleicher id gewinnt der importierte Eintrag (überschreibt den + * bestehenden an derselben Position), neue ids werden angehängt. + */ + function mergeBackupPlaces(existingPlaces, incomingPlaces) { + const merged = new Map(existingPlaces.map((place) => [place.id, place])); + for (const place of incomingPlaces) { + merged.set(place.id, place); + } + return Array.from(merged.values()); + } + + EventMap.import.exportBackup = exportBackup; + EventMap.import.parseBackup = parseBackup; + EventMap.import.mergeBackupPlaces = mergeBackupPlaces; +})(); diff --git a/js/import/importValidator.js b/js/import/importValidator.js new file mode 100644 index 0000000..2e2ef66 --- /dev/null +++ b/js/import/importValidator.js @@ -0,0 +1,271 @@ +// KI-JSON-Import (Phase 7, siehe anforderung.md Abschnitt "Vorgabe für das +// KI-JSON"): reine Validierungslogik ohne Seiteneffekte. Prüft ein vom +// Nutzer eingefügtes/hochgeladenes JSON aus einer externen KI gegen das +// erwartete Format und bewertet jeden Ausflugsziel-Eintrag EINZELN +// (kein Alles-oder-nichts) - ungültige Einträge werden abgelehnt, Einträge +// mit weichen Problemen (unbekannte Kategorie, unsichere URL, ...) bleiben +// gültig, erzeugen aber eine Warnung. Die eigentliche Übernahme ins +// Datenmodell (normalizePlace(), id/createdAt/updatedAt, Kategorie-Auflösung) +// übernimmt js/ui/import.js NACH der Nutzerbestätigung - diese Datei liest +// und schreibt nichts in den Store. +// +// WICHTIG: Es werden ausschließlich Fremddaten (potenziell manipuliert/ +// fehlerhaft) verarbeitet. Diese Datei selbst rendert nichts (kein DOM, +// kein innerHTML) - die Anzeige der Ergebnisse in js/ui/import.js muss +// weiterhin ausschließlich über el()/textContent erfolgen. + +(function () { + const { isValidCoordinate, isSafeHttpUrl, haversineDistanceKm } = EventMap.utils; + const { COST_TYPES, ENVIRONMENT_TYPES } = EventMap.models; + const { CATEGORIES } = EventMap.config; + + // Bestehende Places gelten als Duplikat, wenn Name (getrimmt, + // case-insensitive) übereinstimmt UND die Koordinaten näher als dieser + // Schwellwert (in km) beieinander liegen. + const DUPLICATE_DISTANCE_KM_THRESHOLD = 0.3; + + const CATEGORY_IDS = new Set(CATEGORIES.map((c) => c.id)); + const CATEGORY_LABELS_LOWER = new Set(CATEGORIES.map((c) => c.label.toLowerCase())); + + function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + /** Prüft, ob eine Kategorie-Angabe (Slug ODER Label) einer bekannten Kategorie entspricht. */ + function isKnownCategory(rawCategory) { + if (typeof rawCategory !== 'string') return false; + if (CATEGORY_IDS.has(rawCategory)) return true; + return CATEGORY_LABELS_LOWER.has(rawCategory.trim().toLowerCase()); + } + + /** + * Validiert einen einzelnen rohen Place-Eintrag. + * @returns {{reasons: string[], warnings: string[]}} reasons = Pflichtfeld- + * Verstöße (führen zur Ablehnung des Eintrags), warnings = weiche Probleme + * (Eintrag bleibt gültig, wird aber gekennzeichnet). + */ + function validateEntry(raw, index) { + if (!isPlainObject(raw)) { + return { reasons: [`Eintrag ${index + 1}: Kein gültiges JSON-Objekt.`], warnings: [] }; + } + + const hasName = typeof raw.name === 'string' && raw.name.trim() !== ''; + const label = `Eintrag ${index + 1}${hasName ? ` ("${raw.name.trim()}")` : ''}`; + + const reasons = []; + const warnings = []; + + if (!hasName) { + reasons.push(`${label}: Name fehlt oder ist leer.`); + } + + if (typeof raw.category !== 'string' || raw.category.trim() === '') { + reasons.push(`${label}: Kategorie fehlt.`); + } else if (!isKnownCategory(raw.category)) { + warnings.push(`${label}: Kategorie "${raw.category}" ist unbekannt und wird auf "Sonstige" abgebildet.`); + } + + if ( + typeof raw.latitude !== 'number' || + typeof raw.longitude !== 'number' || + !isValidCoordinate(raw.latitude, raw.longitude) + ) { + reasons.push(`${label}: Koordinaten (latitude/longitude) fehlen oder sind ungültig.`); + } + + if (raw.ageRecommendation !== undefined && !isPlainObject(raw.ageRecommendation)) { + warnings.push(`${label}: "ageRecommendation" hat den falschen Typ und wird ignoriert.`); + } + + if (raw.facilities !== undefined && !isPlainObject(raw.facilities)) { + warnings.push(`${label}: "facilities" hat den falschen Typ und wird ignoriert.`); + } + + if (raw.cost !== undefined && !isPlainObject(raw.cost)) { + warnings.push(`${label}: "cost" hat den falschen Typ und wird ignoriert.`); + } else if ( + isPlainObject(raw.cost) && + raw.cost.type !== undefined && + !COST_TYPES.includes(raw.cost.type) + ) { + warnings.push(`${label}: Kostentyp "${raw.cost.type}" ist unbekannt und wird auf "unknown" gesetzt.`); + } + + if ( + raw.environment !== undefined && + raw.environment !== null && + !ENVIRONMENT_TYPES.includes(raw.environment) + ) { + warnings.push(`${label}: Umgebungswert "${raw.environment}" ist unbekannt und wird auf den Standardwert gesetzt.`); + } + + if (raw.rating !== undefined && raw.rating !== null) { + const r = raw.rating; + if (typeof r !== 'number' || Number.isNaN(r) || r < 0 || r > 5) { + warnings.push(`${label}: Bewertung "${r}" ist ungültig und wird auf "keine Angabe" gesetzt.`); + } + } + + if (raw.website !== undefined && raw.website !== '' && raw.website !== null && !isSafeHttpUrl(raw.website)) { + warnings.push(`${label}: Website-URL ist unsicher/ungültig und wird verworfen.`); + } + + if (raw.image !== undefined && raw.image !== '' && raw.image !== null && !isSafeHttpUrl(raw.image)) { + warnings.push(`${label}: Bild-URL ist unsicher/ungültig und wird verworfen.`); + } + + return { reasons, warnings }; + } + + /** + * Bereinigt einen als gültig eingestuften rohen Eintrag: sicherheitskritische + * bzw. potenziell fehlerhafte Felder (website/image/rating/cost.type/ + * environment/ungültig typisierte verschachtelte Objekte) werden auf einen + * sicheren Zustand zurückgesetzt, DAMIT normalizePlace() (js/models/place.js) + * sie anschließend gefahrlos verarbeiten kann - normalizePlace() selbst + * validiert diese Werte nämlich nicht, sondern übernimmt sie unverändert. + * Alle übrigen Felder bleiben unverändert roh (keine vollständige + * Normalisierung, das bleibt Aufgabe von normalizePlace()). + */ + function sanitizeEntry(raw) { + const sanitized = { ...raw }; + + if (sanitized.ageRecommendation !== undefined && !isPlainObject(sanitized.ageRecommendation)) { + delete sanitized.ageRecommendation; + } + + if (sanitized.facilities !== undefined && !isPlainObject(sanitized.facilities)) { + delete sanitized.facilities; + } + + if (sanitized.cost !== undefined && !isPlainObject(sanitized.cost)) { + delete sanitized.cost; + } else if ( + isPlainObject(sanitized.cost) && + sanitized.cost.type !== undefined && + !COST_TYPES.includes(sanitized.cost.type) + ) { + sanitized.cost = { ...sanitized.cost, type: 'unknown' }; + } + + if ( + sanitized.environment !== undefined && + sanitized.environment !== null && + !ENVIRONMENT_TYPES.includes(sanitized.environment) + ) { + delete sanitized.environment; + } + + if (sanitized.rating !== undefined && sanitized.rating !== null) { + const r = sanitized.rating; + if (typeof r !== 'number' || Number.isNaN(r) || r < 0 || r > 5) { + sanitized.rating = null; + } + } + + if (sanitized.website !== undefined && sanitized.website !== '' && !isSafeHttpUrl(sanitized.website)) { + sanitized.website = ''; + } + + if (sanitized.image !== undefined && sanitized.image !== '' && !isSafeHttpUrl(sanitized.image)) { + sanitized.image = ''; + } + + return sanitized; + } + + /** + * Parst und validiert den Text eines KI-Import-JSON. + * @param {string} jsonText + * @returns {{ + * success: boolean, + * error: string|null, + * warnings: string[], + * validPlaces: Array<{raw: object, warnings: string[]}>, + * rejectedPlaces: Array<{raw: object, reasons: string[]}>, + * }} + */ + function parseAndValidate(jsonText) { + let data; + try { + data = JSON.parse(jsonText); + } catch (err) { + return { + success: false, + error: 'Ungültiges JSON: ' + err.message, + warnings: [], + validPlaces: [], + rejectedPlaces: [], + }; + } + + if (!isPlainObject(data) || !Array.isArray(data.places)) { + return { + success: false, + error: 'Das JSON muss ein Objekt mit einem "places"-Array sein.', + warnings: [], + validPlaces: [], + rejectedPlaces: [], + }; + } + + const warnings = []; + if (typeof data.version !== 'string' || data.version.trim() === '') { + warnings.push('Feld "version" fehlt oder ist leer.'); + } + if (typeof data.region !== 'string' || data.region.trim() === '') { + warnings.push('Feld "region" fehlt oder ist leer.'); + } + if (typeof data.generatedAt !== 'string' || data.generatedAt.trim() === '') { + warnings.push('Feld "generatedAt" fehlt oder ist leer.'); + } + + const validPlaces = []; + const rejectedPlaces = []; + + data.places.forEach((raw, index) => { + const { reasons, warnings: entryWarnings } = validateEntry(raw, index); + if (reasons.length > 0) { + rejectedPlaces.push({ raw, reasons }); + } else { + validPlaces.push({ raw: sanitizeEntry(raw), warnings: entryWarnings }); + } + }); + + return { success: true, error: null, warnings, validPlaces, rejectedPlaces }; + } + + /** + * Sucht unter existingPlaces einen wahrscheinlichen Duplikat-Eintrag für + * candidatePlace (Name übereinstimmend + Koordinaten nah beieinander). + * @param {{name: string, latitude: number, longitude: number}} candidatePlace + * @param {Array} existingPlaces + * @returns {object|null} + */ + function findDuplicate(candidatePlace, existingPlaces) { + if (!candidatePlace || typeof candidatePlace.name !== 'string') return null; + const name = candidatePlace.name.trim().toLowerCase(); + if (!name) return null; + + const { latitude, longitude } = candidatePlace; + if (!isValidCoordinate(latitude, longitude)) return null; + + return ( + (existingPlaces || []).find((existing) => { + if (typeof existing.name !== 'string' || existing.name.trim().toLowerCase() !== name) { + return false; + } + if (!isValidCoordinate(existing.latitude, existing.longitude)) return false; + + const distanceKm = haversineDistanceKm( + { lat: latitude, lng: longitude }, + { lat: existing.latitude, lng: existing.longitude } + ); + return distanceKm <= DUPLICATE_DISTANCE_KM_THRESHOLD; + }) || null + ); + } + + EventMap.import.parseAndValidate = parseAndValidate; + EventMap.import.findDuplicate = findDuplicate; +})(); diff --git a/js/main.js b/js/main.js index 07f92e6..9a74a2f 100644 --- a/js/main.js +++ b/js/main.js @@ -10,6 +10,7 @@ initManualLocationForm, initFilterPanel, initPromptGenerator, + initImport, } = EventMap.ui; function showFatalError(message) { @@ -93,6 +94,15 @@ }); } + function setupImportButton() { + const button = document.getElementById('open-import-btn'); + if (!button) return; + + button.addEventListener('click', () => { + EventMap.ui.openImport(); + }); + } + async function bootstrap() { await initStore(); initMap('map'); @@ -102,10 +112,12 @@ initForm('place-form-modal'); initManualLocationForm('manual-location-modal'); initPromptGenerator('prompt-generator-modal'); + initImport('import-modal'); setupGeolocationButton(); setupManualLocationButton(); setupAddPlaceButton(); setupPromptGeneratorButton(); + setupImportButton(); } setupStatusClose(); diff --git a/js/ui/import.js b/js/ui/import.js new file mode 100644 index 0000000..af76ef2 --- /dev/null +++ b/js/ui/import.js @@ -0,0 +1,568 @@ +// 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; +})(); diff --git a/js/ui/promptGenerator.js b/js/ui/promptGenerator.js index 107dc0f..b6e928b 100644 --- a/js/ui/promptGenerator.js +++ b/js/ui/promptGenerator.js @@ -4,9 +4,10 @@ // Ergebnis live in einem