Seed-Daten per Fetch von externem Server laden, UI-Sprachumschalter (DE/EN) und KI-Übersetzungs-Prompt-Generator ergänzen
- Beispieldaten werden nicht mehr eingebettet (data/seed.js entfernt), sondern beim ersten Start per fetch(EventMap.config.SEED_DATA_URL) von einem externen, selbst gehosteten Server geladen (siehe ADR 0001, Revision 2026-07-28). - Neuer UI-Sprachumschalter (Deutsch/Englisch) für die Bedienoberfläche: js/i18n/strings.js + js/i18n/i18n.js, Sprachwahl im Menü, Persistenz über settings.language, Retranslation ohne Reload. - Neuer Menüpunkt "Ausflugsziel übersetzen (KI)": erzeugt einen Prompt, der eine externe KI bittet, Name/Beschreibungen eines bestehenden Ortes ins Englische, Spanische oder Französische zu übersetzen (js/ai/translatePromptBuilder.js, js/ui/translatePromptGenerator.js). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,25 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-menu__language {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu__language-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu__language-select {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
-1780
File diff suppressed because it is too large
Load Diff
@@ -76,17 +76,31 @@ Abhängigkeiten zugreifen kann.
|
|||||||
Überschreibungen) und macht Abhängigkeiten weniger explizit als
|
Überschreibungen) und macht Abhängigkeiten weniger explizit als
|
||||||
`import`-Statements – sie ergeben sich aus der Script-Reihenfolge in
|
`import`-Statements – sie ergeben sich aus der Script-Reihenfolge in
|
||||||
`index.html`, die entsprechend sorgfältig gepflegt werden muss.
|
`index.html`, die entsprechend sorgfältig gepflegt werden muss.
|
||||||
- **Behobenes Restrisiko:** Ursprünglich lud `js/store/store.js` die
|
- **Behobenes Restrisiko (2026-07-24):** Ursprünglich lud `js/store/store.js`
|
||||||
Beispieldaten per `fetch('data/seed.json')`, was unter `file://`
|
die Beispieldaten per `fetch('data/seed.json')`, was unter `file://`
|
||||||
(insbesondere in Chrome/Chromium) von der Same-Origin-Policy blockiert
|
(insbesondere in Chrome/Chromium) von der Same-Origin-Policy blockiert
|
||||||
wird. Die Seed-Daten wurden daher von `data/seed.json` nach
|
wird. Die Seed-Daten wurden daher von `data/seed.json` nach
|
||||||
`data/seed.js` verschoben und liegen dort als eingebettetes
|
`data/seed.js` verschoben und lagen dort als eingebettetes
|
||||||
`<script>`-Objekt (`EventMap.data.SEED_PLACES`), das wie jede andere
|
`<script>`-Objekt (`EventMap.data.SEED_PLACES`), das wie jede andere
|
||||||
JS-Datei klassisch eingebunden wird. `initStore()` ist dadurch wieder
|
JS-Datei klassisch eingebunden wurde. `initStore()` war dadurch
|
||||||
synchron; `fetch()` kommt aktuell an keiner Stelle der Anwendung mehr
|
synchron. Für spätere Phasen (z.B. KI-JSON-Import) gilt weiterhin:
|
||||||
vor. Für spätere Phasen (z.B. KI-JSON-Import) ist zu beachten: Datei-Uploads
|
Datei-Uploads über `<input type="file">` + `FileReader` sind
|
||||||
über `<input type="file">` + `FileReader` sind unproblematisch unter
|
unproblematisch unter `file://`, ein `fetch()` auf *lokale* Dateien
|
||||||
`file://`, ein `fetch()` auf lokale Dateien dagegen nicht.
|
dagegen nicht.
|
||||||
|
- **Revision (2026-07-28):** `data/seed.js` wurde aus dem Repo entfernt.
|
||||||
|
Die Beispieldaten werden jetzt beim allerersten Start per
|
||||||
|
`fetch(EventMap.config.SEED_DATA_URL)` von einem externen, selbst
|
||||||
|
gehosteten Server geladen (siehe `js/store/store.js`,
|
||||||
|
`loadSeedPlaces()`/`initStore()`). Das ist kein Rückfall in das oben
|
||||||
|
beschriebene Problem, da sich die Same-Origin-Blockade unter `file://`
|
||||||
|
nur auf *lokale* Dateien bezieht – ein `fetch()` auf eine entfernte
|
||||||
|
`https://`-URL funktioniert auch von `file://` aus, sofern der Server
|
||||||
|
passende CORS-Header (`Access-Control-Allow-Origin`) sendet.
|
||||||
|
`initStore()` ist dadurch wieder asynchron (`main.js` awaitet es
|
||||||
|
bereits). Schlägt der Download fehl (offline, fehlende CORS-Header,
|
||||||
|
Server nicht erreichbar), startet die App bewusst mit einer leeren
|
||||||
|
Places-Liste und zeigt einen Hinweis im Status-Banner statt die
|
||||||
|
gesamte Initialisierung abzubrechen.
|
||||||
- Es gibt keinen automatischen Minify-/Tree-Shaking-Schritt; die
|
- Es gibt keinen automatischen Minify-/Tree-Shaking-Schritt; die
|
||||||
Dateigröße im Produktivbetrieb ist etwas größer als mit Bundler, was für
|
Dateigröße im Produktivbetrieb ist etwas größer als mit Bundler, was für
|
||||||
den Umfang dieser Anwendung akzeptabel ist.
|
den Umfang dieser Anwendung akzeptabel ist.
|
||||||
|
|||||||
+15
-12
@@ -33,7 +33,6 @@ js/models/ place.js (Normalisierung), schema.js (Feldstruktur/Defaults)
|
|||||||
js/map/ mapController.js, markers.js, geolocation.js
|
js/map/ mapController.js, markers.js, geolocation.js
|
||||||
js/ui/ list.js (Ergebnisliste; weitere UI-Module folgen in späteren Phasen)
|
js/ui/ list.js (Ergebnisliste; weitere UI-Module folgen in späteren Phasen)
|
||||||
js/utils/ dom.js, geo.js, id.js
|
js/utils/ dom.js, geo.js, id.js
|
||||||
data/seed.js Beispieldaten (10 Ausflugsziele, wird nur beim allerersten Start geladen)
|
|
||||||
vendor/ lokal vendorte Leaflet-Bibliotheken (JS/CSS/Bildassets)
|
vendor/ lokal vendorte Leaflet-Bibliotheken (JS/CSS/Bildassets)
|
||||||
docs/ dieser Plan, ADRs
|
docs/ dieser Plan, ADRs
|
||||||
```
|
```
|
||||||
@@ -53,8 +52,9 @@ aus `anforderung.md` (siehe dortiger Abschnitt "Vorgabe für das KI-JSON"):
|
|||||||
- `website`, `image`, `rating`, `tags[]`, `source`, `sourceVerified`
|
- `website`, `image`, `rating`, `tags[]`, `source`, `sourceVerified`
|
||||||
- Intern generiert: `id` (UUID), `createdAt`, `updatedAt` (ISO-Strings)
|
- Intern generiert: `id` (UUID), `createdAt`, `updatedAt` (ISO-Strings)
|
||||||
|
|
||||||
`js/models/place.js` normalisiert rohe Objekte (aus `data/seed.js` oder späteren
|
`js/models/place.js` normalisiert rohe Objekte (aus den per `EventMap.config.SEED_DATA_URL`
|
||||||
Importen) zu diesem vollständigen internen Format; die Kategorie wird dabei
|
nachgeladenen Beispieldaten oder späteren Importen) zu diesem vollständigen internen
|
||||||
|
Format; die Kategorie wird dabei
|
||||||
immer auf eine gültige Kategorie-`id` (Slug, siehe `js/config/categories.js`)
|
immer auf eine gültige Kategorie-`id` (Slug, siehe `js/config/categories.js`)
|
||||||
aufgelöst – entweder direkt (falls schon ein Slug vorliegt) oder über das
|
aufgelöst – entweder direkt (falls schon ein Slug vorliegt) oder über das
|
||||||
Label (z.B. `"Spielplatz"` → `spielplatz`), wie es die KI liefern würde.
|
Label (z.B. `"Spielplatz"` → `spielplatz`), wie es die KI liefern würde.
|
||||||
@@ -75,9 +75,12 @@ state = { places, filters: null, userLocation: null, selectedPlaceId: null }
|
|||||||
|
|
||||||
- `subscribe(fn)` registriert Listener, `setState(patch)` merged den State und
|
- `subscribe(fn)` registriert Listener, `setState(patch)` merged den State und
|
||||||
benachrichtigt alle Subscriber.
|
benachrichtigt alle Subscriber.
|
||||||
- `initStore()` lädt beim Start persistierte Daten aus LocalStorage
|
- `initStore()` (async) lädt beim Start persistierte Daten aus LocalStorage
|
||||||
(`js/store/storage.js`). Ist noch nichts gespeichert, wird `data/seed.js`
|
(`js/store/storage.js`). Ist noch nichts gespeichert, werden die Beispieldaten
|
||||||
geladen, normalisiert und einmalig persistiert.
|
per `fetch(EventMap.config.SEED_DATA_URL)` nachgeladen, normalisiert und
|
||||||
|
einmalig persistiert. Schlägt der Download fehl (offline, CORS, Server nicht
|
||||||
|
erreichbar), startet die App mit einer leeren Places-Liste und zeigt einen
|
||||||
|
Hinweis im Status-Banner an, statt die Initialisierung abzubrechen.
|
||||||
- Einstellungen (`lastFilters`, `lastMapView`, `userConsentGeo`, `defaultCenter`)
|
- Einstellungen (`lastFilters`, `lastMapView`, `userConsentGeo`, `defaultCenter`)
|
||||||
werden getrennt über `getSettings()`/`updateSettings()` verwaltet und im
|
werden getrennt über `getSettings()`/`updateSettings()` verwaltet und im
|
||||||
Key `eventmap.settings.v1` persistiert.
|
Key `eventmap.settings.v1` persistiert.
|
||||||
@@ -143,12 +146,12 @@ Die App verwendet klassische `<script>`-Tags statt ES-Module (siehe
|
|||||||
`index.html`** direkt aus dem lokal synchronisierten Ordner öffnen
|
`index.html`** direkt aus dem lokal synchronisierten Ordner öffnen
|
||||||
(`file://...`) – ein lokaler HTTP-Server ist dafür nicht mehr nötig.
|
(`file://...`) – ein lokaler HTTP-Server ist dafür nicht mehr nötig.
|
||||||
|
|
||||||
Die Beispieldaten liegen in `data/seed.js` als eingebettetes
|
Die Beispieldaten liegen nicht im Repo, sondern werden beim allerersten Start
|
||||||
`<script>`-Objekt (`EventMap.data.SEED_PLACES`) statt als `.json`-Datei,
|
(leerer LocalStorage) per `fetch(EventMap.config.SEED_DATA_URL)` von einem
|
||||||
da `fetch()` auf lokale Dateien über `file://` in manchen Browsern
|
externen Server geladen (siehe `docs/adr/0001-vanilla-esm.md`). Das
|
||||||
(insbesondere Chrome/Chromium) blockiert wird. `initStore()` liest die
|
funktioniert auch unter `file://`, da die Same-Origin-Blockade in Chrome/Chromium
|
||||||
Seed-Daten dadurch synchron direkt aus dem globalen Namespace, ganz ohne
|
nur `fetch()` auf *lokale* Dateien betrifft, nicht auf entfernte `https://`-URLs
|
||||||
`fetch()`.
|
(vorausgesetzt der Server sendet passende CORS-Header).
|
||||||
|
|
||||||
## Phasenübersicht
|
## Phasenübersicht
|
||||||
|
|
||||||
|
|||||||
+46
-20
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>EventMap – Familienfreundliche Ausflugsziele</title>
|
<title data-i18n="documentTitle">EventMap – Familienfreundliche Ausflugsziele</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
||||||
<link rel="stylesheet" href="vendor/leaflet.markercluster/MarkerCluster.css" />
|
<link rel="stylesheet" href="vendor/leaflet.markercluster/MarkerCluster.css" />
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="app-header__row">
|
<div class="app-header__row">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="app-header__title">EventMap</h1>
|
<h1 class="app-header__title" data-i18n="header.title">EventMap</h1>
|
||||||
<p class="app-header__subtitle">Familienfreundliche Ausflugsziele auf der Karte</p>
|
<p class="app-header__subtitle" data-i18n="header.subtitle">Familienfreundliche Ausflugsziele auf der Karte</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
id="menu-toggle-btn"
|
id="menu-toggle-btn"
|
||||||
@@ -34,32 +34,45 @@
|
|||||||
class="app-header__menu-toggle"
|
class="app-header__menu-toggle"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="app-menu"
|
aria-controls="app-menu"
|
||||||
|
data-i18n-aria-label="header.menuToggleLabel"
|
||||||
aria-label="Menü öffnen"
|
aria-label="Menü öffnen"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">☰</span>
|
<span aria-hidden="true">☰</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav id="app-menu" class="app-menu" aria-label="Aktionen" hidden>
|
<nav id="app-menu" class="app-menu" data-i18n-aria-label="menu.ariaLabel" aria-label="Aktionen" hidden>
|
||||||
<button id="locate-btn" type="button" class="btn btn--secondary">
|
<div class="app-menu__language">
|
||||||
|
<label class="app-menu__language-label" for="language-select" data-i18n="settings.languageLabel">
|
||||||
|
Anzeigesprache
|
||||||
|
</label>
|
||||||
|
<select id="language-select" class="app-menu__language-select">
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button id="locate-btn" type="button" class="btn btn--secondary" data-i18n="menu.locate">
|
||||||
Meinen Standort verwenden
|
Meinen Standort verwenden
|
||||||
</button>
|
</button>
|
||||||
<button id="manual-location-btn" type="button" class="btn btn--secondary">
|
<button id="manual-location-btn" type="button" class="btn btn--secondary" data-i18n="menu.manualLocation">
|
||||||
Standort manuell eingeben
|
Standort manuell eingeben
|
||||||
</button>
|
</button>
|
||||||
<button id="pick-location-btn" type="button" class="btn btn--secondary">
|
<button id="pick-location-btn" type="button" class="btn btn--secondary" data-i18n="menu.pickLocation">
|
||||||
📍 Standort auf Karte wählen
|
📍 Standort auf Karte wählen
|
||||||
</button>
|
</button>
|
||||||
<button id="add-place-btn" type="button" class="btn btn--primary">
|
<button id="add-place-btn" type="button" class="btn btn--primary" data-i18n="menu.addPlace">
|
||||||
+ Neues Ausflugsziel hinzufügen
|
+ Neues Ausflugsziel hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button id="open-prompt-generator-btn" type="button" class="btn btn--secondary">
|
<button id="open-prompt-generator-btn" type="button" class="btn btn--secondary" data-i18n="menu.promptGenerator">
|
||||||
🤖 Neue Ausflugsziele mit KI erstellen
|
🤖 Neue Ausflugsziele mit KI erstellen
|
||||||
</button>
|
</button>
|
||||||
<button id="open-import-btn" type="button" class="btn btn--secondary">
|
<button id="open-translate-prompt-generator-btn" type="button" class="btn btn--secondary" data-i18n="menu.translatePlace">
|
||||||
|
🌐 Ausflugsziel übersetzen (KI)
|
||||||
|
</button>
|
||||||
|
<button id="open-import-btn" type="button" class="btn btn--secondary" data-i18n="menu.import">
|
||||||
📥 KI-Ausflugsziele importieren
|
📥 KI-Ausflugsziele importieren
|
||||||
</button>
|
</button>
|
||||||
<button id="open-backup-btn" type="button" class="btn btn--secondary">
|
<button id="open-backup-btn" type="button" class="btn btn--secondary" data-i18n="menu.backup">
|
||||||
💾 Daten sichern/wiederherstellen
|
💾 Daten sichern/wiederherstellen
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -71,6 +84,7 @@
|
|||||||
id="app-status-close"
|
id="app-status-close"
|
||||||
type="button"
|
type="button"
|
||||||
class="app-status__close"
|
class="app-status__close"
|
||||||
|
data-i18n-aria-label="status.closeLabel"
|
||||||
aria-label="Meldung schließen"
|
aria-label="Meldung schließen"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -78,24 +92,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="app-layout">
|
<main class="app-layout">
|
||||||
<section class="map-panel" aria-label="Karte">
|
<section class="map-panel" data-i18n-aria-label="map.ariaLabel" aria-label="Karte">
|
||||||
<p id="map-picking-hint" class="map-picking-hint" hidden>
|
<p id="map-picking-hint" class="map-picking-hint" hidden>
|
||||||
Klicke auf die Karte, um den Standort auszuwählen.
|
<span data-i18n="map.pickingHint">Klicke auf die Karte, um den Standort auszuwählen.</span>
|
||||||
<button id="map-picking-cancel-btn" type="button" class="map-picking-hint__cancel">
|
<button id="map-picking-cancel-btn" type="button" class="map-picking-hint__cancel" data-i18n="map.pickingCancel">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<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" data-i18n="map.loading">Karte wird geladen…</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="filter-panel" class="filter-panel" aria-label="Filter und Suche"></div>
|
<div id="filter-panel" class="filter-panel" data-i18n-aria-label="filterPanel.ariaLabel" aria-label="Filter und Suche"></div>
|
||||||
|
|
||||||
<aside class="list-panel" aria-label="Ergebnisliste">
|
<aside class="list-panel" data-i18n-aria-label="list.ariaLabel" aria-label="Ergebnisliste">
|
||||||
<h2 class="list-panel__title">Ausflugsziele</h2>
|
<h2 class="list-panel__title" data-i18n="list.title">Ausflugsziele</h2>
|
||||||
<div id="place-list" class="list-panel__content">
|
<div id="place-list" class="list-panel__content">
|
||||||
<p class="list-empty">Lade Ausflugsziele…</p>
|
<p class="list-empty" data-i18n="list.loading">Lade Ausflugsziele…</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
@@ -104,6 +118,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="translate-prompt-generator-modal" class="prompt-generator-modal" hidden></div>
|
||||||
<div id="import-modal" class="import-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>
|
||||||
@@ -119,7 +134,6 @@
|
|||||||
<script src="js/utils/clipboard.js"></script>
|
<script src="js/utils/clipboard.js"></script>
|
||||||
<script src="js/utils/download.js"></script>
|
<script src="js/utils/download.js"></script>
|
||||||
<script src="js/filters/filterEngine.js"></script>
|
<script src="js/filters/filterEngine.js"></script>
|
||||||
<script src="data/seed.js"></script>
|
|
||||||
<script src="js/config/constants.js"></script>
|
<script src="js/config/constants.js"></script>
|
||||||
<script src="js/config/categories.js"></script>
|
<script src="js/config/categories.js"></script>
|
||||||
<script src="js/models/schema.js"></script>
|
<script src="js/models/schema.js"></script>
|
||||||
@@ -127,6 +141,12 @@
|
|||||||
<script src="js/store/migrations.js"></script>
|
<script src="js/store/migrations.js"></script>
|
||||||
<script src="js/store/storage.js"></script>
|
<script src="js/store/storage.js"></script>
|
||||||
<script src="js/store/store.js"></script>
|
<script src="js/store/store.js"></script>
|
||||||
|
|
||||||
|
<!-- UI-Sprachumschalter (ADR 0002): muss nach store.js (liest persistierte
|
||||||
|
Settings synchron beim Init) und vor allen UI-Modulen geladen werden. -->
|
||||||
|
<script src="js/i18n/strings.js"></script>
|
||||||
|
<script src="js/i18n/i18n.js"></script>
|
||||||
|
|
||||||
<script src="js/map/geolocation.js"></script>
|
<script src="js/map/geolocation.js"></script>
|
||||||
<script src="js/map/markers.js"></script>
|
<script src="js/map/markers.js"></script>
|
||||||
<script src="js/map/mapController.js"></script>
|
<script src="js/map/mapController.js"></script>
|
||||||
@@ -142,6 +162,12 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<!-- "Ausflugsziel übersetzen (KI)": erzeugt einen Prompt zum Übersetzen
|
||||||
|
eines bestehenden Ortes (Name/Beschreibungen) in eine Zielsprache
|
||||||
|
(Englisch/Spanisch/Französisch), analog zum obigen Prompt-Generator. -->
|
||||||
|
<script src="js/ai/translatePromptBuilder.js"></script>
|
||||||
|
<script src="js/ui/translatePromptGenerator.js"></script>
|
||||||
|
|
||||||
<!-- KI-JSON-Import + Backup-Export/Import (Phase 7): js/import/*.js sind
|
<!-- KI-JSON-Import + Backup-Export/Import (Phase 7): js/import/*.js sind
|
||||||
reine Validierungs-/Datenfunktionen ohne DOM-Zugriff und setzen daher
|
reine Validierungs-/Datenfunktionen ohne DOM-Zugriff und setzen daher
|
||||||
nur EventMap.utils/models/config/store voraus (bereits geladen);
|
nur EventMap.utils/models/config/store voraus (bereits geladen);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Reine Funktion zur Erzeugung eines Übersetzungs-Prompts für ein bestehendes
|
||||||
|
// Ausflugsziel. Ergänzung zu js/ai/promptBuilder.js (das neue Ausflugsziele
|
||||||
|
// recherchiert) - hier wird stattdessen ein einzelnes, bereits vorhandenes
|
||||||
|
// Place-Objekt an eine externe KI übergeben, mit der Bitte, name/
|
||||||
|
// shortDescription/description in eine Zielsprache zu übersetzen (Englisch/
|
||||||
|
// Spanisch/Französisch) und als JSON zurückzuliefern, das direkt in das
|
||||||
|
// "translations"-Feld des Datenmodells passt (siehe js/models/schema.js,
|
||||||
|
// js/models/place.js).
|
||||||
|
//
|
||||||
|
// Getrennt von EventMap.i18n (UI-Sprachumschalter, js/i18n/*.js): dort geht
|
||||||
|
// es um die Sprache der App-Oberfläche, hier um die Sprache von
|
||||||
|
// Ortsinhalten, die von einer externen KI übersetzt werden - beide Konzepte
|
||||||
|
// bewusst nicht vermischt.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const TARGET_LANGUAGE_LABELS = {
|
||||||
|
en: 'Englisch (English)',
|
||||||
|
es: 'Spanisch (Español)',
|
||||||
|
fr: 'Französisch (Français)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTranslationJsonTemplate(place, targetLanguage) {
|
||||||
|
return `{
|
||||||
|
"id": "${place.id}",
|
||||||
|
"translations": {
|
||||||
|
"${targetLanguage}": {
|
||||||
|
"name": "...",
|
||||||
|
"shortDescription": "...",
|
||||||
|
"description": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} place Bestehendes Place-Objekt (id/name/shortDescription/description werden gelesen)
|
||||||
|
* @param {'en'|'es'|'fr'} targetLanguage
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildTranslationPrompt(place, targetLanguage) {
|
||||||
|
const label = TARGET_LANGUAGE_LABELS[targetLanguage] || targetLanguage;
|
||||||
|
|
||||||
|
const sourceText = JSON.stringify(
|
||||||
|
{
|
||||||
|
name: place.name,
|
||||||
|
shortDescription: place.shortDescription,
|
||||||
|
description: place.description,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
'Du bist ein sorgfältiger Übersetzer für Beschreibungen familienfreundlicher Ausflugsziele.',
|
||||||
|
`Übersetze die folgenden drei Textfelder eines Ausflugsziels ins ${label}. Übersetze inhaltlich ` +
|
||||||
|
'korrekt und natürlich klingend, ohne neue Informationen zu erfinden oder bestehende Informationen ' +
|
||||||
|
'zu verändern. Eigennamen (z.B. Orts- und Straßennamen) bleiben unverändert, sofern im ' +
|
||||||
|
'Zielsprachenraum kein gebräuchlicher, etablierter Name existiert.',
|
||||||
|
'',
|
||||||
|
'1. Ausgangstext (Deutsch)',
|
||||||
|
'```json',
|
||||||
|
sourceText,
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'2. Ausgabeformat',
|
||||||
|
'Antworte ausschließlich mit einem einzigen, gültigen JSON-Objekt - ohne einleitenden oder ' +
|
||||||
|
'abschließenden Fließtext, ohne Markdown-Codeblock-Auszeichnung außerhalb des JSON und ohne ' +
|
||||||
|
'Kommentare innerhalb des JSON. Das Feld "id" muss exakt wie unten vorgegeben übernommen werden.',
|
||||||
|
'',
|
||||||
|
'Zielformat (bitte exakt einhalten, "..." durch die übersetzten Texte ersetzen)',
|
||||||
|
'```json',
|
||||||
|
buildTranslationJsonTemplate(place, targetLanguage),
|
||||||
|
'```',
|
||||||
|
];
|
||||||
|
|
||||||
|
return sections.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
EventMap.ai.buildTranslationPrompt = buildTranslationPrompt;
|
||||||
|
EventMap.ai.TARGET_LANGUAGE_LABELS = TARGET_LANGUAGE_LABELS;
|
||||||
|
})();
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
const CURRENT_SCHEMA_VERSION = 1;
|
const CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
// Beispieldaten fuer den allerersten App-Start (leerer LocalStorage) werden
|
||||||
|
// von dort per fetch() nachgeladen statt im Repo eingebettet zu sein.
|
||||||
|
const SEED_DATA_URL = 'https://web.hhml.selfhost.co/eventmap_seed.json';
|
||||||
|
|
||||||
// Geografische Mitte Deutschlands als Standard-Kartenzentrum,
|
// Geografische Mitte Deutschlands als Standard-Kartenzentrum,
|
||||||
// solange der Nutzer keinen eigenen Standort freigibt oder ein
|
// solange der Nutzer keinen eigenen Standort freigibt oder ein
|
||||||
// eigenes defaultCenter in den Settings hinterlegt hat.
|
// eigenes defaultCenter in den Settings hinterlegt hat.
|
||||||
@@ -20,4 +24,5 @@
|
|||||||
EventMap.config.STORAGE_KEYS = STORAGE_KEYS;
|
EventMap.config.STORAGE_KEYS = STORAGE_KEYS;
|
||||||
EventMap.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
|
EventMap.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
|
||||||
EventMap.config.DEFAULT_MAP_CENTER = DEFAULT_MAP_CENTER;
|
EventMap.config.DEFAULT_MAP_CENTER = DEFAULT_MAP_CENTER;
|
||||||
|
EventMap.config.SEED_DATA_URL = SEED_DATA_URL;
|
||||||
})();
|
})();
|
||||||
|
|||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
// Laufzeit-i18n-Engine für den UI-Sprachumschalter (ADR 0002). Übersetzt die
|
||||||
|
// Bedienoberfläche (Buttons, Labels, Statusmeldungen) live, ohne Reload und
|
||||||
|
// ohne Build-Tooling - passend zur file://-Anforderung aus ADR 0001. Die
|
||||||
|
// Sprachwahl wird über EventMap.store (settings.language) persistiert.
|
||||||
|
//
|
||||||
|
// Setzt EventMap.i18n.STRINGS/SUPPORTED_LANGUAGES aus js/i18n/strings.js
|
||||||
|
// voraus (muss davor geladen sein) sowie EventMap.store (muss vor dieser
|
||||||
|
// Datei in index.html eingebunden sein, da resolveInitialLanguage() bereits
|
||||||
|
// beim Init synchron auf die persistierten Settings zugreift).
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const { STRINGS, SUPPORTED_LANGUAGES } = EventMap.i18n;
|
||||||
|
|
||||||
|
let currentLanguage = 'de';
|
||||||
|
const languageChangeListeners = new Set();
|
||||||
|
const warnedMissingKeys = new Set();
|
||||||
|
|
||||||
|
function lookup(lang, key) {
|
||||||
|
let node = STRINGS[lang];
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
if (node == null) return undefined;
|
||||||
|
node = node[part];
|
||||||
|
}
|
||||||
|
return typeof node === 'string' ? node : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(str, params) {
|
||||||
|
if (!params) return str;
|
||||||
|
return str.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? params[name] : match));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt einen dotted Key (z.B. "menu.locate"). Fallback-Kette: aktuelle Sprache -> Deutsch -> Key selbst. */
|
||||||
|
function t(key, params) {
|
||||||
|
let value = lookup(currentLanguage, key);
|
||||||
|
if (value === undefined) {
|
||||||
|
value = lookup('de', key);
|
||||||
|
}
|
||||||
|
if (value === undefined) {
|
||||||
|
if (!warnedMissingKeys.has(key)) {
|
||||||
|
warnedMissingKeys.add(key);
|
||||||
|
console.warn(`EventMap.i18n: Übersetzungsschlüssel "${key}" nicht gefunden.`);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return interpolate(value, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguage() {
|
||||||
|
return currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übersetzt alle [data-i18n]/[data-i18n-aria-label]/[data-i18n-placeholder]/[data-i18n-title]-Knoten unterhalb von root. */
|
||||||
|
function applyStaticTranslations(root) {
|
||||||
|
const scope = root || document;
|
||||||
|
|
||||||
|
scope.querySelectorAll('[data-i18n]').forEach((node) => {
|
||||||
|
node.textContent = t(node.getAttribute('data-i18n'));
|
||||||
|
});
|
||||||
|
scope.querySelectorAll('[data-i18n-aria-label]').forEach((node) => {
|
||||||
|
node.setAttribute('aria-label', t(node.getAttribute('data-i18n-aria-label')));
|
||||||
|
});
|
||||||
|
scope.querySelectorAll('[data-i18n-placeholder]').forEach((node) => {
|
||||||
|
node.setAttribute('placeholder', t(node.getAttribute('data-i18n-placeholder')));
|
||||||
|
});
|
||||||
|
scope.querySelectorAll('[data-i18n-title]').forEach((node) => {
|
||||||
|
node.setAttribute('title', t(node.getAttribute('data-i18n-title')));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.documentElement.lang = currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registriert einen Listener, der bei jedem Sprachwechsel aufgerufen wird. Gibt eine Unsubscribe-Funktion zurück. */
|
||||||
|
function onLanguageChange(fn) {
|
||||||
|
languageChangeListeners.add(fn);
|
||||||
|
return () => languageChangeListeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyLanguageChange() {
|
||||||
|
for (const fn of languageChangeListeners) {
|
||||||
|
fn(currentLanguage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die initiale Sprache: persistierte Einstellung falls vorhanden,
|
||||||
|
* sonst Browsersprache (navigator.languages), sonst Deutsch als Fallback.
|
||||||
|
*/
|
||||||
|
function resolveInitialLanguage() {
|
||||||
|
const settings = EventMap.store.getSettings();
|
||||||
|
if (settings.language && SUPPORTED_LANGUAGES.includes(settings.language)) {
|
||||||
|
return settings.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserLanguages = navigator.languages && navigator.languages.length
|
||||||
|
? navigator.languages
|
||||||
|
: [navigator.language];
|
||||||
|
|
||||||
|
for (const lang of browserLanguages) {
|
||||||
|
const prefix = (lang || '').slice(0, 2).toLowerCase();
|
||||||
|
if (SUPPORTED_LANGUAGES.includes(prefix)) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'de';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wechselt die Anzeigesprache: persistiert, übersetzt statisches HTML neu und benachrichtigt Listener. */
|
||||||
|
function setLanguage(lang) {
|
||||||
|
if (!SUPPORTED_LANGUAGES.includes(lang) || lang === currentLanguage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLanguage = lang;
|
||||||
|
EventMap.store.updateSettings({ language: lang });
|
||||||
|
applyStaticTranslations(document);
|
||||||
|
notifyLanguageChange();
|
||||||
|
|
||||||
|
// Stößt bei allen Store-Subscribern (Liste, Filter-Panel, ...) einen
|
||||||
|
// Re-Render an, ohne dass sich der eigentliche Datenzustand ändert -
|
||||||
|
// siehe ADR 0002, Abschnitt "Re-Render-Pfad".
|
||||||
|
EventMap.store.setState({});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ermittelt die Startsprache und übersetzt das initiale statische HTML. Wird als Erstes in main.js:bootstrap() aufgerufen. */
|
||||||
|
function init() {
|
||||||
|
currentLanguage = resolveInitialLanguage();
|
||||||
|
applyStaticTranslations(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
EventMap.i18n.t = t;
|
||||||
|
EventMap.i18n.getLanguage = getLanguage;
|
||||||
|
EventMap.i18n.setLanguage = setLanguage;
|
||||||
|
EventMap.i18n.onLanguageChange = onLanguageChange;
|
||||||
|
EventMap.i18n.applyStaticTranslations = applyStaticTranslations;
|
||||||
|
EventMap.i18n.resolveInitialLanguage = resolveInitialLanguage;
|
||||||
|
EventMap.i18n.init = init;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Übersetzungstabelle für die App-Oberfläche (UI-Sprachumschalter, Phase A
|
||||||
|
// laut ADR 0002). Reine Datendatei ohne Logik - die Auflösung/Fallback-Kette
|
||||||
|
// übernimmt js/i18n/i18n.js.
|
||||||
|
//
|
||||||
|
// WICHTIG: Dies ist von EventMap.ai.buildPrompt()/buildTranslationPrompt()
|
||||||
|
// (js/ai/promptBuilder.js, js/ai/translatePromptBuilder.js) komplett getrennt.
|
||||||
|
// Dort geht es um die Sprache, in der eine externe KI Ortsbeschreibungen
|
||||||
|
// verfasst/übersetzt (Datenebene) - hier um die Sprache der Bedienoberfläche
|
||||||
|
// selbst (UI-Ebene). Nicht vermischen.
|
||||||
|
|
||||||
|
window.EventMap = window.EventMap || {};
|
||||||
|
window.EventMap.i18n = window.EventMap.i18n || {};
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const STRINGS = {
|
||||||
|
de: {
|
||||||
|
documentTitle: 'EventMap – Familienfreundliche Ausflugsziele',
|
||||||
|
header: {
|
||||||
|
title: 'EventMap',
|
||||||
|
subtitle: 'Familienfreundliche Ausflugsziele auf der Karte',
|
||||||
|
menuToggleLabel: 'Menü öffnen',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
languageLabel: 'Anzeigesprache',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
ariaLabel: 'Aktionen',
|
||||||
|
locate: 'Meinen Standort verwenden',
|
||||||
|
locating: 'Standort wird ermittelt…',
|
||||||
|
manualLocation: 'Standort manuell eingeben',
|
||||||
|
pickLocation: '📍 Standort auf Karte wählen',
|
||||||
|
pickLocationActive: 'Klicke auf die Karte …',
|
||||||
|
addPlace: '+ Neues Ausflugsziel hinzufügen',
|
||||||
|
promptGenerator: '🤖 Neue Ausflugsziele mit KI erstellen',
|
||||||
|
translatePlace: '🌐 Ausflugsziel übersetzen (KI)',
|
||||||
|
import: '📥 KI-Ausflugsziele importieren',
|
||||||
|
backup: '💾 Daten sichern/wiederherstellen',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
closeLabel: 'Meldung schließen',
|
||||||
|
geolocationError: 'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.',
|
||||||
|
seedLoadFailed: 'Beispieldaten konnten nicht geladen werden. Du kannst trotzdem eigene Ausflugsziele hinzufügen.',
|
||||||
|
bootstrapFailed: 'Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.',
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
ariaLabel: 'Karte',
|
||||||
|
loading: 'Karte wird geladen…',
|
||||||
|
pickingHint: 'Klicke auf die Karte, um den Standort auszuwählen.',
|
||||||
|
pickingCancel: 'Abbrechen',
|
||||||
|
},
|
||||||
|
filterPanel: {
|
||||||
|
ariaLabel: 'Filter und Suche',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
ariaLabel: 'Ergebnisliste',
|
||||||
|
title: 'Ausflugsziele',
|
||||||
|
loading: 'Lade Ausflugsziele…',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
documentTitle: 'EventMap – Family-friendly outing destinations',
|
||||||
|
header: {
|
||||||
|
title: 'EventMap',
|
||||||
|
subtitle: 'Family-friendly outing destinations on the map',
|
||||||
|
menuToggleLabel: 'Open menu',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
languageLabel: 'Display language',
|
||||||
|
},
|
||||||
|
menu: {
|
||||||
|
ariaLabel: 'Actions',
|
||||||
|
locate: 'Use my location',
|
||||||
|
locating: 'Locating…',
|
||||||
|
manualLocation: 'Enter location manually',
|
||||||
|
pickLocation: '📍 Pick location on map',
|
||||||
|
pickLocationActive: 'Click on the map …',
|
||||||
|
addPlace: '+ Add new destination',
|
||||||
|
promptGenerator: '🤖 Create new destinations with AI',
|
||||||
|
translatePlace: '🌐 Translate destination (AI)',
|
||||||
|
import: '📥 Import AI destinations',
|
||||||
|
backup: '💾 Backup/restore data',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
closeLabel: 'Dismiss message',
|
||||||
|
geolocationError: 'Could not determine your location. Please check location permissions in your browser.',
|
||||||
|
seedLoadFailed: 'Sample data could not be loaded. You can still add your own destinations.',
|
||||||
|
bootstrapFailed: 'The application could not be loaded. Please reload the page.',
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
ariaLabel: 'Map',
|
||||||
|
loading: 'Loading map…',
|
||||||
|
pickingHint: 'Click on the map to choose the location.',
|
||||||
|
pickingCancel: 'Cancel',
|
||||||
|
},
|
||||||
|
filterPanel: {
|
||||||
|
ariaLabel: 'Filter and search',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
ariaLabel: 'Results list',
|
||||||
|
title: 'Destinations',
|
||||||
|
loading: 'Loading destinations…',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
EventMap.i18n.STRINGS = STRINGS;
|
||||||
|
EventMap.i18n.SUPPORTED_LANGUAGES = Object.keys(STRINGS);
|
||||||
|
})();
|
||||||
+36
-14
@@ -98,28 +98,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupLanguageSwitcher() {
|
||||||
|
const select = document.getElementById('language-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.value = EventMap.i18n.getLanguage();
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
EventMap.i18n.setLanguage(select.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupGeolocationButton() {
|
function setupGeolocationButton() {
|
||||||
const button = document.getElementById('locate-btn');
|
const button = document.getElementById('locate-btn');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
|
|
||||||
const defaultLabel = button.textContent;
|
|
||||||
|
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Standort wird ermittelt…';
|
button.textContent = EventMap.i18n.t('menu.locating');
|
||||||
|
|
||||||
requestUserLocation({
|
requestUserLocation({
|
||||||
onSuccess: ({ lat, lng }) => {
|
onSuccess: ({ lat, lng }) => {
|
||||||
centerOnCoords(lat, lng, 13);
|
centerOnCoords(lat, lng, 13);
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = defaultLabel;
|
button.textContent = EventMap.i18n.t('menu.locate');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = defaultLabel;
|
button.textContent = EventMap.i18n.t('menu.locate');
|
||||||
showFatalError(
|
showFatalError(EventMap.i18n.t('status.geolocationError'));
|
||||||
'Standort konnte nicht ermittelt werden. Bitte Standortfreigabe im Browser prüfen.'
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,16 +146,14 @@
|
|||||||
const cancelBtn = document.getElementById('map-picking-cancel-btn');
|
const cancelBtn = document.getElementById('map-picking-cancel-btn');
|
||||||
if (!button || !hint || !cancelBtn) return;
|
if (!button || !hint || !cancelBtn) return;
|
||||||
|
|
||||||
const defaultLabel = button.textContent;
|
|
||||||
|
|
||||||
function endPicking() {
|
function endPicking() {
|
||||||
button.textContent = defaultLabel;
|
button.textContent = EventMap.i18n.t('menu.pickLocation');
|
||||||
button.classList.remove('btn--active');
|
button.classList.remove('btn--active');
|
||||||
hint.hidden = true;
|
hint.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function beginPicking() {
|
function beginPicking() {
|
||||||
button.textContent = 'Klicke auf die Karte …';
|
button.textContent = EventMap.i18n.t('menu.pickLocationActive');
|
||||||
button.classList.add('btn--active');
|
button.classList.add('btn--active');
|
||||||
hint.hidden = false;
|
hint.hidden = false;
|
||||||
|
|
||||||
@@ -196,6 +200,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupTranslatePromptGeneratorButton() {
|
||||||
|
const button = document.getElementById('open-translate-prompt-generator-btn');
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
EventMap.ui.openTranslatePromptGenerator();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setupImportButton() {
|
function setupImportButton() {
|
||||||
const button = document.getElementById('open-import-btn');
|
const button = document.getElementById('open-import-btn');
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -215,7 +228,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await initStore();
|
EventMap.i18n.init();
|
||||||
|
setupLanguageSwitcher();
|
||||||
|
|
||||||
|
const { seedLoadFailed } = await initStore();
|
||||||
initMap('map');
|
initMap('map');
|
||||||
initFilterPanel('filter-panel');
|
initFilterPanel('filter-panel');
|
||||||
initList('place-list');
|
initList('place-list');
|
||||||
@@ -223,14 +239,20 @@
|
|||||||
initForm('place-form-modal');
|
initForm('place-form-modal');
|
||||||
initManualLocationForm('manual-location-modal');
|
initManualLocationForm('manual-location-modal');
|
||||||
initPromptGenerator('prompt-generator-modal');
|
initPromptGenerator('prompt-generator-modal');
|
||||||
|
EventMap.ui.initTranslatePromptGenerator('translate-prompt-generator-modal');
|
||||||
initImport('import-modal');
|
initImport('import-modal');
|
||||||
setupGeolocationButton();
|
setupGeolocationButton();
|
||||||
setupManualLocationButton();
|
setupManualLocationButton();
|
||||||
setupPickLocationButton();
|
setupPickLocationButton();
|
||||||
setupAddPlaceButton();
|
setupAddPlaceButton();
|
||||||
setupPromptGeneratorButton();
|
setupPromptGeneratorButton();
|
||||||
|
setupTranslatePromptGeneratorButton();
|
||||||
setupImportButton();
|
setupImportButton();
|
||||||
setupBackupButton();
|
setupBackupButton();
|
||||||
|
|
||||||
|
if (seedLoadFailed) {
|
||||||
|
showFatalError(EventMap.i18n.t('status.seedLoadFailed'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupStatusClose();
|
setupStatusClose();
|
||||||
@@ -238,6 +260,6 @@
|
|||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
console.error('EventMap: Initialisierung fehlgeschlagen.', err);
|
console.error('EventMap: Initialisierung fehlgeschlagen.', err);
|
||||||
showFatalError('Die Anwendung konnte nicht geladen werden. Bitte Seite neu laden.');
|
showFatalError(EventMap.i18n.t('status.bootstrapFailed'));
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+3
-2
@@ -19,8 +19,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalisiert ein rohes Place-Objekt (z.B. aus data/seed.js oder einem
|
* Normalisiert ein rohes Place-Objekt (z.B. aus den per SEED_DATA_URL
|
||||||
* späteren KI-Import) zu einem vollständigen internen Place-Objekt.
|
* geladenen Beispieldaten oder einem späteren KI-Import) zu einem
|
||||||
|
* vollständigen internen Place-Objekt.
|
||||||
* Fehlende Felder werden mit Defaults aufgefüllt, id/createdAt/updatedAt
|
* Fehlende Felder werden mit Defaults aufgefüllt, id/createdAt/updatedAt
|
||||||
* werden übernommen falls vorhanden, sonst neu generiert.
|
* werden übernommen falls vorhanden, sonst neu generiert.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
lastMapView: null,
|
lastMapView: null,
|
||||||
userConsentGeo: null,
|
userConsentGeo: null,
|
||||||
defaultCenter: null,
|
defaultCenter: null,
|
||||||
|
language: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-12
@@ -1,7 +1,8 @@
|
|||||||
// Zentraler Pub/Sub-Store der Anwendung. Hält den Laufzeitzustand
|
// Zentraler Pub/Sub-Store der Anwendung. Hält den Laufzeitzustand
|
||||||
// (places, filters, userLocation, selectedPlaceId) und benachrichtigt
|
// (places, filters, userLocation, selectedPlaceId) und benachrichtigt
|
||||||
// Subscriber bei Änderungen. Lädt beim Start aus LocalStorage bzw. beim
|
// Subscriber bei Änderungen. Lädt beim Start aus LocalStorage bzw. beim
|
||||||
// allerersten Start aus data/seed.js und persistiert Änderungen zurück.
|
// allerersten Start von EventMap.config.SEED_DATA_URL und persistiert
|
||||||
|
// Änderungen zurück.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const { loadData, saveData, loadSettings, saveSettings } = EventMap.store;
|
const { loadData, saveData, loadSettings, saveSettings } = EventMap.store;
|
||||||
@@ -48,29 +49,41 @@
|
|||||||
saveSettings(settings);
|
saveSettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSeedPlaces() {
|
/** Lädt die Beispieldaten von EventMap.config.SEED_DATA_URL nach. Wirft bei Netzwerk-/HTTP-/Parse-Fehlern. */
|
||||||
const raw = (EventMap.data && EventMap.data.SEED_PLACES) || [];
|
async function loadSeedPlaces() {
|
||||||
|
const response = await fetch(EventMap.config.SEED_DATA_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Seed-Daten-Download fehlgeschlagen (HTTP ${response.status}).`);
|
||||||
|
}
|
||||||
|
const raw = await response.json();
|
||||||
return raw.map((item) => normalizePlace(item));
|
return raw.map((item) => normalizePlace(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialisiert den Store: lädt persistierte Places aus LocalStorage oder,
|
* Initialisiert den Store: lädt persistierte Places aus LocalStorage oder,
|
||||||
* falls noch keine vorhanden sind, die eingebetteten Beispieldaten
|
* falls noch keine vorhanden sind, die Beispieldaten von
|
||||||
* (data/seed.js). Gibt den finalen State zurück.
|
* EventMap.config.SEED_DATA_URL. Gibt { seedLoadFailed } zurück, damit
|
||||||
|
* main.js bei einem fehlgeschlagenen Download einen Hinweis anzeigen kann
|
||||||
|
* (die App bleibt dabei mit einer leeren Places-Liste nutzbar).
|
||||||
*/
|
*/
|
||||||
function initStore() {
|
async function initStore() {
|
||||||
const persisted = loadData();
|
const persisted = loadData();
|
||||||
let places = [];
|
|
||||||
|
|
||||||
if (persisted && Array.isArray(persisted.places) && persisted.places.length > 0) {
|
if (persisted && Array.isArray(persisted.places) && persisted.places.length > 0) {
|
||||||
places = persisted.places.map((p) => normalizePlace(p));
|
setState({ places: persisted.places.map((p) => normalizePlace(p)) });
|
||||||
} else {
|
return { seedLoadFailed: false };
|
||||||
places = loadSeedPlaces();
|
|
||||||
saveData(places);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const places = await loadSeedPlaces();
|
||||||
|
saveData(places);
|
||||||
setState({ places });
|
setState({ places });
|
||||||
return state;
|
return { seedLoadFailed: false };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('EventMap: Beispieldaten konnten nicht geladen werden.', err);
|
||||||
|
setState({ places: [] });
|
||||||
|
return { seedLoadFailed: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persistiert state.places nach Änderungen (Erstellen/Bearbeiten/Löschen). */
|
/** Persistiert state.places nach Änderungen (Erstellen/Bearbeiten/Löschen). */
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// "Ausflugsziel übersetzen (KI)"-Modal: lässt Nutzer ein bestehendes
|
||||||
|
// Ausflugsziel und eine Zielsprache (Englisch/Spanisch/Französisch)
|
||||||
|
// auswählen und erzeugt daraus per EventMap.ai.buildTranslationPrompt()
|
||||||
|
// (js/ai/translatePromptBuilder.js) einen fertigen, kopierbaren
|
||||||
|
// Übersetzungs-Prompt für eine externe KI.
|
||||||
|
//
|
||||||
|
// Aufbau/Muster bewusst analog zu js/ui/promptGenerator.js (gleiche
|
||||||
|
// Modal-Chrome-Klassen aus css/promptGenerator.css, gleicher Copy/Download-
|
||||||
|
// Ablauf). Rendering ausschließlich über textContent/DOM-APIs (el()).
|
||||||
|
//
|
||||||
|
// WICHTIG: Diese Datei erzeugt nur den Prompt. Die JSON-Antwort der KI (mit
|
||||||
|
// dem übersetzten "translations"-Feld) muss aktuell noch manuell in die
|
||||||
|
// Ortsdaten übernommen werden - ein automatischer Import/Merge ist (noch)
|
||||||
|
// nicht Teil dieser Datei.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const { el, clearChildren } = EventMap.utils;
|
||||||
|
|
||||||
|
const TARGET_LANGUAGE_OPTIONS = [
|
||||||
|
{ value: 'en', label: 'Englisch' },
|
||||||
|
{ value: 'es', label: 'Spanisch' },
|
||||||
|
{ value: 'fr', label: 'Französisch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let container = null;
|
||||||
|
let refs = null;
|
||||||
|
|
||||||
|
function field(id, labelText, inputNode) {
|
||||||
|
inputNode.id = id;
|
||||||
|
return el('div', { className: 'form-field' }, [
|
||||||
|
el('label', { className: 'form-field__label', attrs: { for: id } }, [labelText]),
|
||||||
|
inputNode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaceOptions() {
|
||||||
|
const { places } = EventMap.store.getState();
|
||||||
|
const sorted = [...places].sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de'));
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
return [el('option', { attrs: { value: '' }, text: '(Keine Ausflugsziele vorhanden)' })];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.map((place) =>
|
||||||
|
el('option', {
|
||||||
|
attrs: { value: place.id },
|
||||||
|
text: place.city ? `${place.name} (${place.city})` : place.name,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkeleton(rootContainer) {
|
||||||
|
refs = {};
|
||||||
|
|
||||||
|
refs.backdrop = el('div', {
|
||||||
|
className: 'prompt-generator-modal__backdrop',
|
||||||
|
on: { click: () => closeTranslatePromptGenerator() },
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.closeBtn = el(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
className: 'prompt-generator-modal__close',
|
||||||
|
attrs: { type: 'button', 'aria-label': 'Übersetzungs-Prompt-Generator schließen' },
|
||||||
|
on: { click: () => closeTranslatePromptGenerator() },
|
||||||
|
},
|
||||||
|
['✕']
|
||||||
|
);
|
||||||
|
|
||||||
|
refs.title = el('h2', {
|
||||||
|
className: 'prompt-generator__title',
|
||||||
|
attrs: { id: 'translate-prompt-generator-title' },
|
||||||
|
text: '🌐 Ausflugsziel übersetzen (KI)',
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.intro = el('p', {
|
||||||
|
className: 'prompt-generator__intro',
|
||||||
|
text:
|
||||||
|
'Wähle ein bestehendes Ausflugsziel und eine Zielsprache aus, um einen fertigen Prompt für eine ' +
|
||||||
|
'externe KI (z.B. ChatGPT, Claude, Gemini) zu erzeugen. Der Prompt bittet die KI, Name, Kurz- und ' +
|
||||||
|
'ausführliche Beschreibung in die gewählte Sprache zu übersetzen.',
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.placeSelect = el('select', { on: { change: updateOutput } }, buildPlaceOptions());
|
||||||
|
refs.languageSelect = el(
|
||||||
|
'select',
|
||||||
|
{ on: { change: updateOutput } },
|
||||||
|
TARGET_LANGUAGE_OPTIONS.map((opt) => el('option', { attrs: { value: opt.value }, text: opt.label }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = el('div', { className: 'place-form prompt-generator__form' }, [
|
||||||
|
el('fieldset', { className: 'place-form__section' }, [
|
||||||
|
el('legend', { className: 'place-form__section-label', text: 'Auswahl' }),
|
||||||
|
field('tpg-place', 'Ausflugsziel', refs.placeSelect),
|
||||||
|
field('tpg-language', 'Zielsprache', refs.languageSelect),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
refs.output = el('textarea', {
|
||||||
|
className: 'prompt-generator__output-textarea',
|
||||||
|
attrs: { readonly: '', id: 'translate-prompt-generator-output', rows: '16' },
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.copyBtn = el(
|
||||||
|
'button',
|
||||||
|
{ className: 'btn btn--primary', attrs: { type: 'button' }, on: { click: handleCopyClick } },
|
||||||
|
['In Zwischenablage kopieren']
|
||||||
|
);
|
||||||
|
refs.downloadBtn = el(
|
||||||
|
'button',
|
||||||
|
{ className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: handleDownloadClick } },
|
||||||
|
['Als Textdatei speichern']
|
||||||
|
);
|
||||||
|
refs.copyStatus = el('p', { className: 'prompt-generator__status', attrs: { role: 'status' } });
|
||||||
|
|
||||||
|
const outputSection = el('div', { className: 'prompt-generator__output' }, [
|
||||||
|
el('h3', { className: 'place-form__section-label', text: 'Generierter Prompt' }),
|
||||||
|
refs.output,
|
||||||
|
el('div', { className: 'prompt-generator__output-actions' }, [refs.copyBtn, refs.downloadBtn, refs.copyStatus]),
|
||||||
|
el('div', { className: 'prompt-generator__instructions' }, [
|
||||||
|
el('h3', { className: 'place-form__section-label', text: 'Anleitung' }),
|
||||||
|
el('ol', { className: 'prompt-generator__instructions-list' }, [
|
||||||
|
el('li', { text: 'Ausflugsziel und Zielsprache auswählen.' }),
|
||||||
|
el('li', { text: 'Prompt kopieren oder als Textdatei speichern.' }),
|
||||||
|
el('li', {
|
||||||
|
text: 'In eine KI (z.B. ChatGPT, Claude, Gemini) einfügen und die Antwort abwarten.',
|
||||||
|
}),
|
||||||
|
el('li', {
|
||||||
|
text:
|
||||||
|
'Die JSON-Antwort enthält das Feld "translations" - dieses aktuell noch manuell in die ' +
|
||||||
|
'gespeicherten Ortsdaten übernehmen (automatischer Import folgt ggf. später).',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const layout = el('div', { className: 'prompt-generator__layout' }, [form, outputSection]);
|
||||||
|
|
||||||
|
refs.panel = el(
|
||||||
|
'div',
|
||||||
|
{
|
||||||
|
className: 'prompt-generator-modal__panel',
|
||||||
|
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'translate-prompt-generator-title' },
|
||||||
|
},
|
||||||
|
[refs.closeBtn, refs.title, refs.intro, layout]
|
||||||
|
);
|
||||||
|
|
||||||
|
rootContainer.appendChild(refs.backdrop);
|
||||||
|
rootContainer.appendChild(refs.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den Prompt live neu. Ohne auswählbares Ausflugsziel wird stattdessen ein Hinweis angezeigt. */
|
||||||
|
function updateOutput() {
|
||||||
|
const { places } = EventMap.store.getState();
|
||||||
|
const place = places.find((p) => p.id === refs.placeSelect.value);
|
||||||
|
|
||||||
|
if (!place) {
|
||||||
|
refs.output.value = '(Bitte zuerst ein Ausflugsziel anlegen, um einen Übersetzungs-Prompt zu erzeugen.)';
|
||||||
|
refs.copyBtn.disabled = true;
|
||||||
|
refs.downloadBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.output.value = EventMap.ai.buildTranslationPrompt(place, refs.languageSelect.value);
|
||||||
|
refs.copyBtn.disabled = false;
|
||||||
|
refs.downloadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCopyStatus() {
|
||||||
|
refs.copyStatus.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyClick() {
|
||||||
|
const success = await EventMap.utils.copyToClipboard(refs.output.value);
|
||||||
|
const defaultLabel = 'In Zwischenablage kopieren';
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
refs.copyBtn.textContent = 'Kopiert!';
|
||||||
|
refs.copyStatus.textContent = '';
|
||||||
|
} else {
|
||||||
|
refs.copyBtn.textContent = defaultLabel;
|
||||||
|
refs.copyStatus.textContent =
|
||||||
|
'Kopieren war nicht möglich. Bitte den Text im Feld oben manuell markieren und kopieren.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
refs.copyBtn.textContent = defaultLabel;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadClick() {
|
||||||
|
EventMap.utils.downloadTextFile('eventmap-uebersetzung-prompt.txt', refs.output.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die Ortsauswahl neu auf (z.B. falls seit dem letzten Öffnen Orte hinzugekommen sind), behält die Auswahl falls noch gültig. */
|
||||||
|
function refreshPlaceOptions() {
|
||||||
|
const currentValue = refs.placeSelect.value;
|
||||||
|
clearChildren(refs.placeSelect);
|
||||||
|
buildPlaceOptions().forEach((option) => refs.placeSelect.appendChild(option));
|
||||||
|
if ([...refs.placeSelect.options].some((opt) => opt.value === currentValue)) {
|
||||||
|
refs.placeSelect.value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet den Übersetzungs-Prompt-Generator. */
|
||||||
|
function openTranslatePromptGenerator() {
|
||||||
|
if (!container || !refs) {
|
||||||
|
console.error(
|
||||||
|
'EventMap: Übersetzungs-Prompt-Generator wurde noch nicht initialisiert (initTranslatePromptGenerator()).'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPlaceOptions();
|
||||||
|
resetCopyStatus();
|
||||||
|
updateOutput();
|
||||||
|
|
||||||
|
container.hidden = false;
|
||||||
|
document.body.classList.add('prompt-generator-modal-open');
|
||||||
|
refs.placeSelect.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTranslatePromptGenerator() {
|
||||||
|
if (!container || container.hidden) return;
|
||||||
|
container.hidden = true;
|
||||||
|
document.body.classList.remove('prompt-generator-modal-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialisiert das Übersetzungs-Prompt-Generator-Modal im Element mit der übergebenen id. */
|
||||||
|
function initTranslatePromptGenerator(containerId) {
|
||||||
|
const containerEl = document.getElementById(containerId);
|
||||||
|
if (!containerEl) {
|
||||||
|
console.error(`EventMap: Übersetzungs-Prompt-Generator-Container #${containerId} nicht gefunden.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container = containerEl;
|
||||||
|
clearChildren(container);
|
||||||
|
buildSkeleton(container);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && !container.hidden) {
|
||||||
|
closeTranslatePromptGenerator();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
EventMap.ui.initTranslatePromptGenerator = initTranslatePromptGenerator;
|
||||||
|
EventMap.ui.openTranslatePromptGenerator = openTranslatePromptGenerator;
|
||||||
|
})();
|
||||||
@@ -18,6 +18,7 @@ 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 || {};
|
window.EventMap.import = window.EventMap.import || {};
|
||||||
|
window.EventMap.i18n = window.EventMap.i18n || {};
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Fügt Orte aus einem KI-Import-JSON (Format wie in anforderung.md
|
|
||||||
// "Vorgabe für das KI-JSON" beschrieben, z.B. Export aus dem
|
|
||||||
// Prompt-Generator) dauerhaft zu data/seed.js hinzu.
|
|
||||||
//
|
|
||||||
// Nutzung:
|
|
||||||
// node scripts/add-seed-places.js <pfad-zur-ki-datei.json> [--dry-run]
|
|
||||||
//
|
|
||||||
// Lädt die echten App-Module (normalizePlace, Kategorie-Auflösung per
|
|
||||||
// Label, generateId) per vm-Sandbox aus js/, statt deren Logik hier
|
|
||||||
// erneut nachzubauen - so bleibt das Skript automatisch konsistent mit
|
|
||||||
// der App, auch wenn sich Kategorien oder Default-Felder dort ändern.
|
|
||||||
// Neue Einträge werden als Text vor dem abschließenden "];" eingefügt;
|
|
||||||
// bestehende Einträge in seed.js bleiben dabei byteidentisch erhalten.
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const vm = require('vm');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
|
||||||
const SEED_PATH = path.join(ROOT, 'data', 'seed.js');
|
|
||||||
|
|
||||||
function loadAppSandbox() {
|
|
||||||
// window muss der Kontext-Global selbst sein (nicht nur eine Property
|
|
||||||
// darauf), damit die App-Dateien - die im Browser als Sloppy-Mode-Scripts
|
|
||||||
// laufen und "EventMap" bar (ohne "window.") referenzieren - dieselbe
|
|
||||||
// globale Bindung sehen wie "window.EventMap".
|
|
||||||
const sandbox = { crypto: { randomUUID: crypto.randomUUID } };
|
|
||||||
sandbox.window = sandbox;
|
|
||||||
vm.createContext(sandbox);
|
|
||||||
|
|
||||||
// Reihenfolge muss der Abhängigkeitskette aus index.html entsprechen.
|
|
||||||
const files = [
|
|
||||||
'js/utils/dom.js',
|
|
||||||
'js/utils/id.js',
|
|
||||||
'js/config/categories.js',
|
|
||||||
'js/models/schema.js',
|
|
||||||
'js/models/place.js',
|
|
||||||
];
|
|
||||||
for (const file of files) {
|
|
||||||
const code = fs.readFileSync(path.join(ROOT, file), 'utf8');
|
|
||||||
vm.runInContext(code, sandbox, { filename: file });
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedCode = fs.readFileSync(SEED_PATH, 'utf8');
|
|
||||||
vm.runInContext(seedCode, sandbox, { filename: 'data/seed.js' });
|
|
||||||
|
|
||||||
return sandbox.EventMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inlineTags(tags) {
|
|
||||||
return '[' + tags.map((t) => JSON.stringify(t)).join(', ') + ']';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formatiert einen normalisierten Place exakt im Stil der bestehenden seed.js-Einträge. */
|
|
||||||
function formatPlace(p) {
|
|
||||||
const lines = [
|
|
||||||
` "id": ${JSON.stringify(p.id)},`,
|
|
||||||
` "name": ${JSON.stringify(p.name)},`,
|
|
||||||
` "category": ${JSON.stringify(p.category)},`,
|
|
||||||
` "shortDescription": ${JSON.stringify(p.shortDescription)},`,
|
|
||||||
` "description": ${JSON.stringify(p.description)},`,
|
|
||||||
` "latitude": ${JSON.stringify(p.latitude)},`,
|
|
||||||
` "longitude": ${JSON.stringify(p.longitude)},`,
|
|
||||||
` "address": ${JSON.stringify(p.address)},`,
|
|
||||||
` "city": ${JSON.stringify(p.city)},`,
|
|
||||||
` "region": ${JSON.stringify(p.region)},`,
|
|
||||||
` "ageRecommendation": { "min": ${JSON.stringify(p.ageRecommendation.min)}, "max": ${JSON.stringify(p.ageRecommendation.max)} },`,
|
|
||||||
` "cost": { "type": ${JSON.stringify(p.cost.type)}, "description": ${JSON.stringify(p.cost.description)} },`,
|
|
||||||
` "openingHours": ${JSON.stringify(p.openingHours)},`,
|
|
||||||
` "durationMinutes": ${JSON.stringify(p.durationMinutes)},`,
|
|
||||||
` "facilities": { "strollerAccessible": ${p.facilities.strollerAccessible}, "wheelchairAccessible": ${p.facilities.wheelchairAccessible}, "toilets": ${p.facilities.toilets}, "parking": ${p.facilities.parking}, "restaurant": ${p.facilities.restaurant} },`,
|
|
||||||
` "environment": ${JSON.stringify(p.environment)},`,
|
|
||||||
` "website": ${JSON.stringify(p.website)},`,
|
|
||||||
` "image": ${JSON.stringify(p.image)},`,
|
|
||||||
` "rating": ${JSON.stringify(p.rating)},`,
|
|
||||||
` "tags": ${inlineTags(p.tags)},`,
|
|
||||||
` "source": ${JSON.stringify(p.source)},`,
|
|
||||||
` "sourceVerified": ${p.sourceVerified},`,
|
|
||||||
` "createdAt": ${JSON.stringify(p.createdAt)},`,
|
|
||||||
` "updatedAt": ${JSON.stringify(p.updatedAt)}`,
|
|
||||||
];
|
|
||||||
return ' {\n' + lines.join('\n') + '\n }';
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const dryRun = args.includes('--dry-run');
|
|
||||||
const inputPath = args.find((a) => !a.startsWith('--'));
|
|
||||||
|
|
||||||
if (!inputPath) {
|
|
||||||
console.error('Nutzung: node scripts/add-seed-places.js <pfad-zur-ki-datei.json> [--dry-run]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EventMap = loadAppSandbox();
|
|
||||||
const existingPlaces = EventMap.data.SEED_PLACES;
|
|
||||||
const existingNames = new Set(existingPlaces.map((p) => p.name.trim().toLowerCase()));
|
|
||||||
|
|
||||||
const inputRaw = fs.readFileSync(path.resolve(inputPath), 'utf8');
|
|
||||||
const input = JSON.parse(inputRaw);
|
|
||||||
const candidates = Array.isArray(input.places) ? input.places : [];
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
console.error('Keine "places" im angegebenen JSON gefunden.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toAdd = [];
|
|
||||||
const skipped = [];
|
|
||||||
|
|
||||||
for (const raw of candidates) {
|
|
||||||
const key = String(raw.name || '').trim().toLowerCase();
|
|
||||||
if (!key || existingNames.has(key)) {
|
|
||||||
skipped.push(raw.name || '(ohne Namen)');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalized = EventMap.models.normalizePlace(raw);
|
|
||||||
toAdd.push(normalized);
|
|
||||||
existingNames.add(key); // schützt auch vor Duplikaten innerhalb derselben Eingabedatei
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Gefunden: ${candidates.length}, neu: ${toAdd.length}, übersprungen (Duplikat/Name fehlt): ${skipped.length}`);
|
|
||||||
if (skipped.length > 0) {
|
|
||||||
console.log('Übersprungen:', skipped.join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toAdd.length === 0) {
|
|
||||||
console.log('Nichts zu tun.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedText = fs.readFileSync(SEED_PATH, 'utf8');
|
|
||||||
const closingIndex = seedText.lastIndexOf('\n];');
|
|
||||||
if (closingIndex === -1) {
|
|
||||||
console.error('Konnte das Ende von EventMap.data.SEED_PLACES in seed.js nicht finden.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const before = seedText.slice(0, closingIndex);
|
|
||||||
const after = seedText.slice(closingIndex); // beginnt mit "\n];"
|
|
||||||
|
|
||||||
const newBlocks = toAdd.map(formatPlace).join(',\n');
|
|
||||||
const updated = `${before},\n${newBlocks}${after}`;
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log('--dry-run: seed.js wird nicht verändert. Neue Blöcke:\n');
|
|
||||||
console.log(newBlocks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(SEED_PATH, updated, 'utf8');
|
|
||||||
console.log(`data/seed.js aktualisiert: ${toAdd.length} neue Einträge hinzugefügt.`);
|
|
||||||
console.log(toAdd.map((p) => ` - ${p.name}`).join('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
Executable
+276
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fügt neue Ausflugsziele aus einem KI-Import-JSON (Format wie in
|
||||||
|
anforderung.md "Vorgabe für das KI-JSON") zur eventmap_seed.json auf dem
|
||||||
|
Server hinzu (per SMB-Mount erreichbar, Default: gvfs-Freigabe unter
|
||||||
|
smb://172.16.1.4/web).
|
||||||
|
|
||||||
|
Eingabe wahlweise per Datei oder Zwischenablage:
|
||||||
|
python3 add_places_from_ai.py # liest aus der Zwischenablage
|
||||||
|
python3 add_places_from_ai.py --file ki.json # liest aus einer Datei
|
||||||
|
python3 add_places_from_ai.py --file - < ki.json
|
||||||
|
|
||||||
|
Duplikat-Check: Name + Ort (case-insensitive, whitespace-normalisiert),
|
||||||
|
sowohl gegen bestehende Einträge als auch innerhalb der neuen Eingabe.
|
||||||
|
|
||||||
|
Vor jedem Schreibvorgang wird ein Backup der Zieldatei angelegt
|
||||||
|
(eventmap_seed.json.bak-<timestamp>), da es sich um eine gemeinsam
|
||||||
|
genutzte Datei auf dem Server handelt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_TARGET = (
|
||||||
|
"/run/user/1000/gvfs/smb-share:server=172.16.1.4,share=web/eventmap_seed.json"
|
||||||
|
)
|
||||||
|
DEFAULT_SMB_URL = "smb://172.16.1.4/web"
|
||||||
|
|
||||||
|
# Muss mit js/config/categories.js in Sync bleiben.
|
||||||
|
CATEGORIES = [
|
||||||
|
("naturpark", "Naturpark"),
|
||||||
|
("wald-wanderweg", "Wald/Wanderweg"),
|
||||||
|
("see-badestelle", "See/Badestelle"),
|
||||||
|
("erlebnispark", "Erlebnispark"),
|
||||||
|
("freizeitpark", "Freizeitpark"),
|
||||||
|
("tierpark-zoo", "Tierpark/Zoo"),
|
||||||
|
("spielplatz", "Spielplatz"),
|
||||||
|
("museum-kinder", "Museum für Kinder"),
|
||||||
|
("indoor-spielplatz", "Indoor-Spielplatz"),
|
||||||
|
("bauernhof", "Bauernhof"),
|
||||||
|
("kletterpark", "Kletterpark"),
|
||||||
|
("aussichtspunkt", "Aussichtspunkt"),
|
||||||
|
("cafe-restaurant", "Familienfreundliches Café/Restaurant"),
|
||||||
|
("sonstige-aktivitaet", "Sonstige kinderfreundliche Aktivität"),
|
||||||
|
("sonstige", "Sonstige"),
|
||||||
|
]
|
||||||
|
CATEGORY_IDS = {cid for cid, _ in CATEGORIES}
|
||||||
|
CATEGORY_ID_BY_LABEL = {label.lower(): cid for cid, label in CATEGORIES}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_category(raw):
|
||||||
|
if isinstance(raw, str):
|
||||||
|
if raw in CATEGORY_IDS:
|
||||||
|
return raw
|
||||||
|
return CATEGORY_ID_BY_LABEL.get(raw.strip().lower(), "sonstige")
|
||||||
|
return "sonstige"
|
||||||
|
|
||||||
|
|
||||||
|
def default_place_fields():
|
||||||
|
return {
|
||||||
|
"name": "",
|
||||||
|
"category": "sonstige",
|
||||||
|
"shortDescription": "",
|
||||||
|
"description": "",
|
||||||
|
"latitude": None,
|
||||||
|
"longitude": None,
|
||||||
|
"address": "",
|
||||||
|
"city": "",
|
||||||
|
"region": "",
|
||||||
|
"ageRecommendation": {"min": None, "max": None},
|
||||||
|
"cost": {"type": "unknown", "description": ""},
|
||||||
|
"openingHours": "",
|
||||||
|
"durationMinutes": None,
|
||||||
|
"facilities": {
|
||||||
|
"strollerAccessible": False,
|
||||||
|
"wheelchairAccessible": False,
|
||||||
|
"toilets": False,
|
||||||
|
"parking": False,
|
||||||
|
"restaurant": False,
|
||||||
|
},
|
||||||
|
"environment": "outdoor",
|
||||||
|
"website": "",
|
||||||
|
"image": "",
|
||||||
|
"rating": None,
|
||||||
|
"tags": [],
|
||||||
|
"source": "",
|
||||||
|
"sourceVerified": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iso_now():
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_place(raw):
|
||||||
|
defaults = default_place_fields()
|
||||||
|
now_iso = iso_now()
|
||||||
|
return {
|
||||||
|
"id": raw.get("id") or str(uuid.uuid4()),
|
||||||
|
"name": raw.get("name") or defaults["name"],
|
||||||
|
"category": resolve_category(raw.get("category")),
|
||||||
|
"shortDescription": raw.get("shortDescription") or defaults["shortDescription"],
|
||||||
|
"description": raw.get("description") or defaults["description"],
|
||||||
|
"latitude": raw.get("latitude", defaults["latitude"]),
|
||||||
|
"longitude": raw.get("longitude", defaults["longitude"]),
|
||||||
|
"address": raw.get("address") or defaults["address"],
|
||||||
|
"city": raw.get("city") or defaults["city"],
|
||||||
|
"region": raw.get("region") or defaults["region"],
|
||||||
|
"ageRecommendation": {**defaults["ageRecommendation"], **(raw.get("ageRecommendation") or {})},
|
||||||
|
"cost": {**defaults["cost"], **(raw.get("cost") or {})},
|
||||||
|
"openingHours": raw.get("openingHours") or defaults["openingHours"],
|
||||||
|
"durationMinutes": raw.get("durationMinutes", defaults["durationMinutes"]),
|
||||||
|
"facilities": {**defaults["facilities"], **(raw.get("facilities") or {})},
|
||||||
|
"environment": raw.get("environment") or defaults["environment"],
|
||||||
|
"website": raw.get("website") or defaults["website"],
|
||||||
|
"image": raw.get("image") or defaults["image"],
|
||||||
|
"rating": raw.get("rating", defaults["rating"]),
|
||||||
|
"tags": list(raw["tags"]) if isinstance(raw.get("tags"), list) else [],
|
||||||
|
"source": raw.get("source") or defaults["source"],
|
||||||
|
"sourceVerified": bool(raw.get("sourceVerified", defaults["sourceVerified"])),
|
||||||
|
"createdAt": raw.get("createdAt") or now_iso,
|
||||||
|
"updatedAt": raw.get("updatedAt") or now_iso,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_json_text(text):
|
||||||
|
text = text.strip()
|
||||||
|
fence = re.match(r"^```(?:json)?\s*\n(.*)\n```$", text, re.DOTALL)
|
||||||
|
return fence.group(1) if fence else text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_candidates(data):
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("places"), list):
|
||||||
|
return data["places"]
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
if isinstance(data, dict) and "name" in data:
|
||||||
|
return [data]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_input(file_arg):
|
||||||
|
if file_arg is None:
|
||||||
|
try:
|
||||||
|
import pyperclip
|
||||||
|
except ImportError:
|
||||||
|
sys.exit(
|
||||||
|
"pyperclip ist nicht installiert (für die Zwischenablage nötig).\n"
|
||||||
|
"Installieren mit: pip install pyperclip\n"
|
||||||
|
"Unter Linux zusätzlich ein Clipboard-Tool: 'xclip' oder 'xsel' (X11) "
|
||||||
|
"bzw. 'wl-clipboard' (Wayland).\n"
|
||||||
|
"Alternativ das JSON in eine Datei speichern und mit --file übergeben."
|
||||||
|
)
|
||||||
|
text = pyperclip.paste()
|
||||||
|
if not text or not text.strip():
|
||||||
|
sys.exit("Zwischenablage ist leer.")
|
||||||
|
print("Lese KI-JSON aus der Zwischenablage ...")
|
||||||
|
return text
|
||||||
|
if file_arg == "-":
|
||||||
|
print("Lese KI-JSON von stdin ...")
|
||||||
|
return sys.stdin.read()
|
||||||
|
print(f"Lese KI-JSON aus Datei: {file_arg}")
|
||||||
|
return Path(file_arg).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mounted(target, smb_url, automount):
|
||||||
|
parent = Path(target).parent
|
||||||
|
if parent.is_dir():
|
||||||
|
return
|
||||||
|
if not automount:
|
||||||
|
sys.exit(
|
||||||
|
f"Fehler: {parent} existiert nicht. Bitte die SMB-Freigabe zuerst mounten "
|
||||||
|
f"(z.B. im Dateimanager öffnen oder: gio mount {smb_url})"
|
||||||
|
)
|
||||||
|
print(f"SMB-Freigabe scheint nicht gemountet zu sein, versuche: gio mount {smb_url}")
|
||||||
|
try:
|
||||||
|
subprocess.run(["gio", "mount", smb_url], check=True, timeout=60)
|
||||||
|
except (OSError, subprocess.SubprocessError) as err:
|
||||||
|
sys.exit(f"Automatisches Mounten fehlgeschlagen ({err}).")
|
||||||
|
if not parent.is_dir():
|
||||||
|
sys.exit(f"Fehler: {parent} existiert immer noch nicht nach dem Mount-Versuch.")
|
||||||
|
|
||||||
|
|
||||||
|
def norm_key_part(value):
|
||||||
|
return re.sub(r"\s+", " ", str(value or "")).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def dedup_key(place):
|
||||||
|
return (norm_key_part(place["name"]), norm_key_part(place["city"]))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--file", "-f", help="Pfad zur KI-JSON-Datei ('-' für stdin). Ohne Angabe: Zwischenablage.")
|
||||||
|
parser.add_argument("--target", "-t", default=DEFAULT_TARGET, help=f"Pfad zur eventmap_seed.json (Default: {DEFAULT_TARGET})")
|
||||||
|
parser.add_argument("--smb-url", default=DEFAULT_SMB_URL, help=f"SMB-URL zum Automount (Default: {DEFAULT_SMB_URL})")
|
||||||
|
parser.add_argument("--no-automount", action="store_true", help="Nicht automatisch versuchen, die Freigabe zu mounten.")
|
||||||
|
parser.add_argument("--no-backup", action="store_true", help="Kein Backup der Zieldatei vor dem Schreiben anlegen.")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, was passieren würde, nichts schreiben.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
raw_text = read_input(args.file)
|
||||||
|
try:
|
||||||
|
data = json.loads(extract_json_text(raw_text))
|
||||||
|
except json.JSONDecodeError as err:
|
||||||
|
sys.exit(f"Ungültiges JSON: {err}")
|
||||||
|
|
||||||
|
candidates = extract_candidates(data)
|
||||||
|
if not candidates:
|
||||||
|
sys.exit('Kein "places"-Array bzw. keine Orte im JSON gefunden.')
|
||||||
|
|
||||||
|
ensure_mounted(args.target, args.smb_url, automount=not args.no_automount)
|
||||||
|
|
||||||
|
target_path = Path(args.target)
|
||||||
|
if target_path.exists():
|
||||||
|
existing = json.loads(target_path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
print(f"Hinweis: {target_path} existiert noch nicht, starte mit leerer Liste.")
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
key_owner = {dedup_key(p): p.get("name", "?") for p in existing}
|
||||||
|
|
||||||
|
to_add = []
|
||||||
|
skipped = []
|
||||||
|
for raw in candidates:
|
||||||
|
if not isinstance(raw, dict) or not str(raw.get("name") or "").strip():
|
||||||
|
skipped.append(("(ohne Namen)", "kein Name angegeben"))
|
||||||
|
continue
|
||||||
|
place = normalize_place(raw)
|
||||||
|
key = dedup_key(place)
|
||||||
|
if key in key_owner:
|
||||||
|
skipped.append((place["name"], f"Duplikat von {key_owner[key]!r}"))
|
||||||
|
continue
|
||||||
|
key_owner[key] = place["name"]
|
||||||
|
to_add.append(place)
|
||||||
|
|
||||||
|
print(f"Gefunden: {len(candidates)}, neu: {len(to_add)}, übersprungen: {len(skipped)}")
|
||||||
|
for name, reason in skipped:
|
||||||
|
print(f" - übersprungen: {name} ({reason})")
|
||||||
|
|
||||||
|
if not to_add:
|
||||||
|
print("Nichts zu tun.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Neu hinzuzufügen:")
|
||||||
|
for p in to_add:
|
||||||
|
print(f" - {p['name']} ({p['category']}, {p['city'] or 'ohne Ort'})")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("--dry-run: Zieldatei wird nicht verändert.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if target_path.exists() and not args.no_backup:
|
||||||
|
backup_path = target_path.with_name(
|
||||||
|
target_path.name + f".bak-{datetime.datetime.now().strftime('%Y%m%dT%H%M%S')}"
|
||||||
|
)
|
||||||
|
# Reines Byte-Kopieren statt shutil.copy2/copy: der gvfs-SMB-Mount
|
||||||
|
# unterstuetzt kein chmod, das shutil beim Metadaten-Kopieren aufruft.
|
||||||
|
backup_path.write_bytes(target_path.read_bytes())
|
||||||
|
print(f"Backup angelegt: {backup_path}")
|
||||||
|
|
||||||
|
merged = existing + to_add
|
||||||
|
target_path.write_text(
|
||||||
|
json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(f"{target_path} aktualisiert: {len(to_add)} neue Einträge hinzugefügt (gesamt: {len(merged)}).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user