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:
@@ -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