- 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>
277 lines
10 KiB
Python
Executable File
277 lines
10 KiB
Python
Executable File
#!/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()
|