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:
2026-07-28 15:16:31 +02:00
co-authored by Claude Sonnet 5
parent 4eb595aa81
commit 2aaff8f680
17 changed files with 1027 additions and 2007 deletions
+19
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+81
View File
@@ -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;
})();
+5
View File
@@ -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
View File
@@ -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;
})();
+108
View File
@@ -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
View File
@@ -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
View File
@@ -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.
*/ */
+1
View File
@@ -11,6 +11,7 @@
lastMapView: null, lastMapView: null,
userConsentGeo: null, userConsentGeo: null,
defaultCenter: null, defaultCenter: null,
language: null,
}; };
} }
+25 -12
View File
@@ -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). */
+250
View File
@@ -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;
})();
+1
View File
@@ -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 () {
/** /**
-158
View File
@@ -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();
+276
View File
@@ -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()