// 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; })();