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
-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()