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:
Vendored
+267
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-1
@@ -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 |
|
| 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) |
|
| 5 | Route/Navigation-Integration | ✅ bereits in Phase 2 erledigt (OSM-Directions-Button in der Detailansicht) |
|
||||||
| 6 | KI-Prompt-Generator (Eingabeformular, Promptgenerierung, Copy/Download) | ✅ umgesetzt |
|
| 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)
|
## KI-Prompt-Generator (Phase 6)
|
||||||
|
|
||||||
|
|||||||
+13
@@ -18,6 +18,7 @@
|
|||||||
<link rel="stylesheet" href="css/filters.css" />
|
<link rel="stylesheet" href="css/filters.css" />
|
||||||
<link rel="stylesheet" href="css/promptGenerator.css" />
|
<link rel="stylesheet" href="css/promptGenerator.css" />
|
||||||
<link rel="stylesheet" href="css/manualLocation.css" />
|
<link rel="stylesheet" href="css/manualLocation.css" />
|
||||||
|
<link rel="stylesheet" href="css/import.css" />
|
||||||
<link rel="stylesheet" href="css/responsive.css" />
|
<link rel="stylesheet" href="css/responsive.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -53,6 +54,9 @@
|
|||||||
<button id="open-prompt-generator-btn" type="button" class="btn btn--secondary">
|
<button id="open-prompt-generator-btn" type="button" class="btn btn--secondary">
|
||||||
🤖 Neue Ausflugsziele mit KI erstellen
|
🤖 Neue Ausflugsziele mit KI erstellen
|
||||||
</button>
|
</button>
|
||||||
|
<button id="open-import-btn" type="button" class="btn btn--secondary">
|
||||||
|
📥 KI-Ausflugsziele importieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="map" class="map-panel__map">
|
<div id="map" class="map-panel__map">
|
||||||
<p class="map-panel__loading">Karte wird geladen…</p>
|
<p class="map-panel__loading">Karte wird geladen…</p>
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
<div id="place-form-modal" class="place-form-modal" hidden></div>
|
<div id="place-form-modal" class="place-form-modal" hidden></div>
|
||||||
<div id="manual-location-modal" class="manual-location-modal" hidden></div>
|
<div id="manual-location-modal" class="manual-location-modal" hidden></div>
|
||||||
<div id="prompt-generator-modal" class="prompt-generator-modal" hidden></div>
|
<div id="prompt-generator-modal" class="prompt-generator-modal" hidden></div>
|
||||||
|
<div id="import-modal" class="import-modal" hidden></div>
|
||||||
|
|
||||||
<script src="vendor/leaflet/leaflet.js"></script>
|
<script src="vendor/leaflet/leaflet.js"></script>
|
||||||
<script src="vendor/leaflet.markercluster/leaflet.markercluster.js"></script>
|
<script src="vendor/leaflet.markercluster/leaflet.markercluster.js"></script>
|
||||||
@@ -110,6 +115,14 @@
|
|||||||
<script src="js/ai/promptBuilder.js"></script>
|
<script src="js/ai/promptBuilder.js"></script>
|
||||||
<script src="js/ui/promptGenerator.js"></script>
|
<script src="js/ui/promptGenerator.js"></script>
|
||||||
|
|
||||||
|
<!-- KI-JSON-Import + Backup-Export/Import (Phase 7): js/import/*.js sind
|
||||||
|
reine Validierungs-/Datenfunktionen ohne DOM-Zugriff und setzen daher
|
||||||
|
nur EventMap.utils/models/config/store voraus (bereits geladen);
|
||||||
|
js/ui/import.js baut darauf auf. -->
|
||||||
|
<script src="js/import/importValidator.js"></script>
|
||||||
|
<script src="js/import/backup.js"></script>
|
||||||
|
<script src="js/ui/import.js"></script>
|
||||||
|
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
+12
@@ -10,6 +10,7 @@
|
|||||||
initManualLocationForm,
|
initManualLocationForm,
|
||||||
initFilterPanel,
|
initFilterPanel,
|
||||||
initPromptGenerator,
|
initPromptGenerator,
|
||||||
|
initImport,
|
||||||
} = EventMap.ui;
|
} = EventMap.ui;
|
||||||
|
|
||||||
function showFatalError(message) {
|
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() {
|
async function bootstrap() {
|
||||||
await initStore();
|
await initStore();
|
||||||
initMap('map');
|
initMap('map');
|
||||||
@@ -102,10 +112,12 @@
|
|||||||
initForm('place-form-modal');
|
initForm('place-form-modal');
|
||||||
initManualLocationForm('manual-location-modal');
|
initManualLocationForm('manual-location-modal');
|
||||||
initPromptGenerator('prompt-generator-modal');
|
initPromptGenerator('prompt-generator-modal');
|
||||||
|
initImport('import-modal');
|
||||||
setupGeolocationButton();
|
setupGeolocationButton();
|
||||||
setupManualLocationButton();
|
setupManualLocationButton();
|
||||||
setupAddPlaceButton();
|
setupAddPlaceButton();
|
||||||
setupPromptGeneratorButton();
|
setupPromptGeneratorButton();
|
||||||
|
setupImportButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupStatusClose();
|
setupStatusClose();
|
||||||
|
|||||||
+568
@@ -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;
|
||||||
|
})();
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
// Ergebnis live in einem <textarea readonly> an. Bietet zusätzlich Buttons
|
// Ergebnis live in einem <textarea readonly> an. Bietet zusätzlich Buttons
|
||||||
// zum Kopieren (Zwischenablage) und Speichern als Textdatei.
|
// zum Kopieren (Zwischenablage) und Speichern als Textdatei.
|
||||||
//
|
//
|
||||||
// WICHTIG: Diese Phase implementiert nur die Prompt-Erzeugung. Der spätere
|
// WICHTIG: Diese Datei implementiert nur die Prompt-Erzeugung. Der JSON-Import
|
||||||
// JSON-Import (Einlesen der KI-Antwort zurück in die App) ist NICHT Teil
|
// (Einlesen der KI-Antwort zurück in die App) ist NICHT Teil dieser Datei,
|
||||||
// dieser Datei - das folgt in Phase 7.
|
// sondern liegt in js/ui/import.js (Phase 7, Button "📥 KI-Ausflugsziele
|
||||||
|
// importieren" in der Toolbar).
|
||||||
//
|
//
|
||||||
// Aufbau/Muster analog zu js/ui/form.js (Modal mit Backdrop, Escape schließt,
|
// Aufbau/Muster analog zu js/ui/form.js (Modal mit Backdrop, Escape schließt,
|
||||||
// Fokus auf erstes Feld beim Öffnen). Rendering ausschließlich über
|
// Fokus auf erstes Feld beim Öffnen). Rendering ausschließlich über
|
||||||
@@ -278,8 +279,8 @@
|
|||||||
}),
|
}),
|
||||||
el('li', {
|
el('li', {
|
||||||
text:
|
text:
|
||||||
'Die JSON-Antwort der KI im nächsten Schritt (KI-JSON-Import, in einer späteren Version ' +
|
'Die JSON-Antwort der KI kopieren und über den Button "📥 KI-Ausflugsziele importieren" ' +
|
||||||
'dieser Anwendung) prüfen und importieren.',
|
'(direkt neben diesem Button) prüfen und importieren.',
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ window.EventMap.map = window.EventMap.map || {};
|
|||||||
window.EventMap.ui = window.EventMap.ui || {};
|
window.EventMap.ui = window.EventMap.ui || {};
|
||||||
window.EventMap.filters = window.EventMap.filters || {};
|
window.EventMap.filters = window.EventMap.filters || {};
|
||||||
window.EventMap.ai = window.EventMap.ai || {};
|
window.EventMap.ai = window.EventMap.ai || {};
|
||||||
|
window.EventMap.import = window.EventMap.import || {};
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user