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.
This commit is contained in:
2026-07-24 12:50:05 +02:00
parent b49b8f4f47
commit 39321d680f
9 changed files with 1236 additions and 6 deletions
+271
View File
@@ -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;
})();