#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Stammdaten aufräumen: Zutaten, Einheiten und Schlagworte. cleanup.py pruefen Sucht Dubletten, ähnliche Namen und unbenutzte Einträge und schreibt einen Plan. Verändert nichts. cleanup.py ausfuehren --plan [--apply] Arbeitet den Plan ab. Ohne --apply nur eine Trockenübung. Warum das mit Vorsicht zu genießen ist -------------------------------------- Tandoors merge hängt alle Verweise der Quelle auf das Ziel um und löscht die Quelle danach. Das ist **nicht umkehrbar**. Im Tandoor-Quelltext steht dazu: # TODO these checks could be improved to merge existing properties and # conversion in a smart way. For now it will just loose them to prevent # duplicates if isinstance(source, Food): source.properties.all().delete() Die Nährwerte der Quelle gehen also verloren. Deshalb rettet dieses Werkzeug sie vorher: Werte, die nur die Quelle hat, werden zuerst ans Ziel geschrieben (--ohne-rettung schaltet das ab). Und deshalb wird nur zusammengeführt, was praktisch sicher dasselbe ist — alles Grenzwertige landet nur in einer Liste zum Anschauen. Standalone: python3 plugins/tandoor-cleanup/tool/cleanup.py pruefen """ from __future__ import annotations import argparse import json import os import secrets import sys from datetime import datetime, timezone from pathlib import Path from typing import Any SUITE_ROOT = Path(__file__).resolve().parents[3] if str(SUITE_ROOT) not in sys.path: sys.path.insert(0, str(SUITE_ROOT)) from core.tandoor import ( # noqa: E402 TandoorClient, TandoorError, food_property_map, merged_properties_payload, ) from core.foodmatch import ( # noqa: E402 duplicate_groups, similar_pairs, looks_like_plural, guess_plural, ) from core import backups # noqa: E402 from core import ai # noqa: E402 KINDS = { "food": {"endpoint": "food", "label": "Zutat", "plural": "Zutaten"}, "unit": {"endpoint": "unit", "label": "Einheit", "plural": "Einheiten"}, "keyword": {"endpoint": "keyword", "label": "Schlagwort", "plural": "Schlagworte"}, } def out(text: str = "") -> None: print(text, flush=True) def data_dir() -> Path: configured = os.environ.get("DATA_DIR", "").strip() base = Path(configured) if configured else SUITE_ROOT / "data" / "tandoor-cleanup" base.mkdir(parents=True, exist_ok=True) return base def stamp() -> str: return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") def short(obj: dict[str, Any]) -> dict[str, Any]: return { "id": obj.get("id"), "name": obj.get("name"), "plural_name": obj.get("plural_name") or "", "numrecipe": obj.get("numrecipe") or 0, "properties": len(obj.get("properties") or []), } def reference_index(client: TandoorClient) -> dict[str, Any]: """ Wer zeigt worauf? Einmal alle Rezepte lesen und merken. Das muss VOR dem Zusammenführen passieren: Danach zeigen die Verweise auf das Ziel und die alte Zuordnung ist nicht mehr rekonstruierbar. Genau diese Aufzeichnung ist es, die ein Rückspielen später überhaupt möglich macht. """ out("Rezeptverweise werden aufgezeichnet …") foods: dict[int, list[dict[str, Any]]] = {} units: dict[int, list[dict[str, Any]]] = {} keywords: dict[int, list[dict[str, Any]]] = {} overview = client.list_objects("recipe") for index, entry in enumerate(overview, start=1): rid = entry.get("id") try: recipe = client.get_json(f"api/recipe/{rid}/") except TandoorError: continue kw_ids = [k["id"] for k in recipe.get("keywords") or [] if isinstance(k, dict) and isinstance(k.get("id"), int)] for kid in kw_ids: keywords.setdefault(kid, []).append({ "recipe_id": rid, "recipe_name": recipe.get("name"), # Die vollständige Liste, nicht nur der eine Treffer: Beim # Zurückspielen wird sie eins zu eins wiederhergestellt. "keywords_before": kw_ids, }) for step in recipe.get("steps") or []: for ing in step.get("ingredients") or []: iid = ing.get("id") if not isinstance(iid, int): continue food = (ing.get("food") or {}).get("id") unit = (ing.get("unit") or {}).get("id") if isinstance(food, int): foods.setdefault(food, []).append({ "ingredient_id": iid, "recipe_id": rid, "recipe_name": recipe.get("name"), "food_id": food, }) if isinstance(unit, int): units.setdefault(unit, []).append({ "ingredient_id": iid, "recipe_id": rid, "recipe_name": recipe.get("name"), "unit_id": unit, }) if index % 25 == 0 or index == len(overview): out(f" {index}/{len(overview)}") return {"food": foods, "unit": units, "keyword": keywords} # ------------------------------------------------------------------ Prüfen def scan(client: TandoorClient, args: argparse.Namespace) -> dict[str, Any]: kinds = [k.strip() for k in args.arten.split(",") if k.strip()] if args.arten else list(KINDS) unknown = [k for k in kinds if k not in KINDS] if unknown: raise SystemExit(f"Unbekannte Art: {unknown}. Erlaubt: {', '.join(KINDS)}") result: dict[str, Any] = { "created_at": datetime.now(timezone.utc).isoformat(), "kinds": {}, "actions": [], } # Rezepte einmal lesen, um benutzte Zutaten/Einheiten/Schlagworte zu # bestimmen. Wichtig: Tandoors Food-LISTE liefert „numrecipe“ oft nicht mit # (dann stünde es überall auf 0 und ALLE Zutaten wären scheinbar unbenutzt). # Deshalb bestimmen wir die Benutzung verlässlich aus den Rezepten selbst. used_foods: set[int] = set() used_units: set[int] = set() used_keywords: set[int] = set() if kinds: out("Rezepte werden gelesen, um Benutzung zu bestimmen …") overview = client.list_objects("recipe") for index, entry in enumerate(overview, start=1): try: recipe = client.get_json(f"api/recipe/{entry['id']}/") except TandoorError: continue for kw in recipe.get("keywords") or []: if isinstance(kw, dict) and isinstance(kw.get("id"), int): used_keywords.add(kw["id"]) for step in recipe.get("steps") or []: for ing in step.get("ingredients") or []: food = (ing.get("food") or {}).get("id") if isinstance(food, int): used_foods.add(food) unit = ing.get("unit") if isinstance(unit, dict) and isinstance(unit.get("id"), int): used_units.add(unit["id"]) if index % 25 == 0 or index == len(overview): out(f" {index}/{len(overview)}") counter = {"n": 0} def new_aid() -> str: counter["n"] += 1 return f"a{counter['n']:04d}" for kind in kinds: endpoint = KINDS[kind]["endpoint"] out(f"{KINDS[kind]['plural']} werden geprüft …") objects = client.list_objects(endpoint) by_id = {o["id"]: o for o in objects} out(f" {len(objects)} Einträge") groups = duplicate_groups(objects) pairs = similar_pairs(objects) def plural_for_merge(source_obj: dict, target_obj: dict) -> str: """ Beim Zusammenführen: Wenn die Quelle wie ein Plural des Ziels aussieht und das Ziel noch keinen Plural gepflegt hat, diesen vorschlagen. """ if kind != "food": return "" if (target_obj.get("plural_name") or "").strip(): return "" src = (source_obj.get("name") or "").strip() tgt = (target_obj.get("name") or "").strip() return src if looks_like_plural(tgt, src) else "" # Unbenutzt = kommt in keinem Rezept vor. Für Zutaten ist der # Rezept-Scan maßgeblich; numrecipe zählt zusätzlich als „benutzt“, # falls es doch mitgeliefert wird — so wird nie etwas fälschlich als # unbenutzt (und damit löschbar) markiert. if kind == "food": unused = [short(o) for o in objects if o.get("id") not in used_foods and not (o.get("numrecipe") or 0)] elif kind == "unit": unused = [short(o) for o in objects if o.get("id") not in used_units] else: unused = [short(o) for o in objects if o.get("id") not in used_keywords] unused.sort(key=lambda o: (o["name"] or "").casefold()) # ---------------------------------------------------- sichere Dubletten plan_groups = [] for group in groups: target = group["target"] target_obj = by_id[target["id"]] sources = [] for source in group["sources"]: source_obj = by_id[source["id"]] verlust = [] if kind == "food": ziel_werte = food_property_map(target_obj) quell_werte = food_property_map(source_obj) verlust = [ tid for tid, wert in quell_werte.items() if wert is not None and ziel_werte.get(tid) is None ] pv = plural_for_merge(source_obj, target_obj) aid = new_aid() sources.append({ **short(source), "aid": aid, "rescue_properties": verlust, "plural_suggestion": pv, }) result["actions"].append({ "aid": aid, "kind": kind, "endpoint": endpoint, "action": "merge", "origin": "duplicate", "source_id": source["id"], "source_name": source.get("name"), "target_id": target["id"], "target_name": target.get("name"), "rescue_properties": verlust, "reason": group["reason"], "score": group["score"], # Für Food: Plural am Ziel ergänzen, wenn sinnvoll — und # dann auch gleich vorausgewählt, denn das ist genau der # Fall, den man haben will. "set_plural": bool(pv), "plural_value": pv, "accept": True, }) plan_groups.append({ "target": short(target), "sources": sources, "score": group["score"], "reason": group["reason"], "recipes_affected": group["recipes_affected"], }) # ------------------------------------------------------- ähnliche Paare # Anders als bisher: jetzt bedienbar. Häkchen und Richtung wählt der # Nutzer in der Oberfläche; hier wird nur der Kandidat vorbereitet, # ohne Vorauswahl und ohne Richtung. similar = [] for pair in pairs: left, right = short(pair["left"]), short(pair["right"]) aid = new_aid() pv_lr = plural_for_merge(by_id[left["id"]], by_id[right["id"]]) # links->rechts pv_rl = plural_for_merge(by_id[right["id"]], by_id[left["id"]]) # rechts->links similar.append({ "aid": aid, "left": left, "right": right, "score": pair["score"], "reason": pair["reason"], "plural_left_to_right": pv_lr, "plural_right_to_left": pv_rl, }) result["actions"].append({ "aid": aid, "kind": kind, "endpoint": endpoint, "action": "merge", "origin": "similar", "left_id": left["id"], "left_name": left["name"], "right_id": right["id"], "right_name": right["name"], # Richtung offen — die Oberfläche setzt source_id/target_id auf # genau eines der beiden Paar-Mitglieder. "source_id": None, "source_name": None, "target_id": None, "target_name": None, "rescue_properties": [], "reason": pair["reason"], "score": pair["score"], "set_plural": False, "plural_value": "", # Pluralvorschlag je Richtung, damit die Oberfläche beim # Umschalten sofort das Richtige anbietet. "plural_left_to_right": pv_lr, "plural_right_to_left": pv_rl, "accept": False, }) # ---------------------------------------------------- Plurale pflegen # Vollständige Sicht auf die Plurale — für Zutaten UND Einheiten. Jeder # Eintrag ist einzeln korrigier- und eintragbar. Nicht destruktiv, voll # rückspielbar, nichts vorausgewählt. Schlagworte haben keinen Plural. plurals = [] if kind in ("food", "unit"): for o in objects: aktuell = (o.get("plural_name") or "").strip() aid = new_aid() vorschlag = guess_plural(o.get("name") or "") if not aktuell else "" # Benutzung aus dem Rezept-Scan (numrecipe ist unzuverlässig). if kind == "food": benutzt = 1 if (o.get("id") in used_foods or (o.get("numrecipe") or 0)) else 0 else: benutzt = 1 if o.get("id") in used_units else 0 plurals.append({ "aid": aid, "id": o["id"], "name": o.get("name"), "plural": aktuell, "used": benutzt, "suggestion": vorschlag, }) result["actions"].append({ "aid": aid, "kind": kind, "endpoint": endpoint, "action": "set_plural", "source_id": o["id"], "source_name": o.get("name"), "target_id": None, "target_name": None, # Vorbelegung = aktueller Plural, damit Bearbeiten den Stand zeigt. "plural_value": aktuell, "plural_before_scan": aktuell, "reason": "Plural pflegen", "score": 0, "accept": False, }) # Fehlende Plurale zuerst, dann die meistbenutzten, dann alphabetisch. plurals.sort(key=lambda m: (bool(m["plural"]), -(m["used"]), (m["name"] or "").casefold())) # ------------------------------------------------------------ Löschen for item in unused: result["actions"].append({ "aid": new_aid(), "kind": kind, "endpoint": endpoint, "action": "delete", "source_id": item["id"], "source_name": item["name"], "target_id": None, "target_name": None, "rescue_properties": [], "reason": "in keinem Rezept verwendet", "score": 0, # Löschen wird bewusst NICHT vorausgewählt. "accept": False, }) result["kinds"][kind] = { "label": KINDS[kind]["label"], "plural": KINDS[kind]["plural"], "total": len(objects), "duplicate_groups": plan_groups, "similar": similar, "unused": unused, "plurals": plurals, } # KI-Vorfilter: definitiv verschiedene Paare aussortieren, bevor die Liste # überhaupt erscheint. Nur wenn gewünscht und ein Schlüssel da ist. if getattr(args, "ki_filter", False): if not os.environ.get("OPENAI_API_KEY"): out("KI-Vorauswahl übersprungen — OPENAI_API_KEY fehlt.") else: model = getattr(args, "model", None) or os.environ.get("OPENAI_MODEL", "gpt-5.5") try: z = _ai_classify_similars(result, model) result["ai_filtered"] = True out(f"KI-Vorauswahl: {z['geprueft']} Paare — {z['gleich']} gleich " f"(angehakt), {z['unklar']} unklar, {z['verschieden']} verschieden " f"(ausgeblendet).") except Exception as exc: # noqa: BLE001 out(f"KI-Vorauswahl fehlgeschlagen (Paare bleiben unverändert): {exc}") return result def print_scan(plan: dict[str, Any]) -> None: out() out("─" * 64) for kind, data in plan["kinds"].items(): merges = sum(len(g["sources"]) for g in data["duplicate_groups"]) out(f" {data['plural']:<14} {data['total']:>4} Einträge · " f"{merges} zusammenführbar · {len(data['similar'])} zur Ansicht · " f"{len(data['unused'])} unbenutzt") out("─" * 64) for kind, data in plan["kinds"].items(): if data["duplicate_groups"]: out() out(f" {data['plural']} — sicher dasselbe:") for group in data["duplicate_groups"]: t = group["target"] out(f" Ziel: {t['name']} [{t['id']}] " f"({t['numrecipe']} Rezepte, {t['properties']} Eigenschaften)") for s in group["sources"]: rettung = (f" ⚠ {len(s['rescue_properties'])} Werte werden vorher gerettet" if s["rescue_properties"] else "") out(f" ← {s['name']} [{s['id']}] " f"({s['numrecipe']} Rezepte){rettung}") out(f" {group['reason']} · {group['score']}%") if data["similar"]: out() out(f" {data['plural']} — ähnlich, bitte selbst entscheiden " f"(nicht vorausgewählt):") for pair in data["similar"][:15]: out(f" {pair['left']['name']} [{pair['left']['id']}] ~ " f"{pair['right']['name']} [{pair['right']['id']}] " f"{pair['score']}% · {pair['reason']}") if data["unused"]: out() out(f" {data['plural']} — unbenutzt ({len(data['unused'])}, " f"Löschen ist nicht vorausgewählt):") for item in data["unused"][:15]: out(f" {item['name']} [{item['id']}]") if len(data["unused"]) > 15: out(f" … und {len(data['unused']) - 15} weitere") # -------------------------------------------------------------- Ausführen def rescue_properties( client: TandoorClient, source_id: int, target_id: int, dry: bool, ) -> tuple[str, list[int]]: """ Werte, die nur die Quelle hat, vor dem Zusammenführen ans Ziel retten. Ermittelt selbst, welche Eigenschaften das Ziel noch fehlen und die Quelle hat — nötig, weil bei „ähnlichen“ Paaren die Richtung erst in der Oberfläche gewählt wird. Gibt (Notiz, gerettete Eigenschafts-IDs) zurück. """ source = client.get_json(f"api/food/{source_id}/") target = client.get_json(f"api/food/{target_id}/") quelle = food_property_map(source) ziel = food_property_map(target) updates = { tid: wert for tid, wert in quelle.items() if wert is not None and ziel.get(tid) is None } if not updates: return "", [] if dry: return f"würde {len(updates)} Werte ans Ziel retten", list(updates) body: dict[str, Any] = {"properties": merged_properties_payload(target, updates)} if not target.get("properties_food_amount") and source.get("properties_food_amount"): body["properties_food_amount"] = source["properties_food_amount"] unit = source.get("properties_food_unit") if isinstance(unit, dict) and unit.get("id"): body["properties_food_unit"] = {"id": unit["id"], "name": unit.get("name")} client.patch_json(f"api/food/{target_id}/", body) return f"{len(updates)} Werte gerettet", list(updates) def _set_plural(client: TandoorClient, endpoint: str, obj: dict[str, Any], plural: str) -> str: """ Setzt ``plural_name`` so robust wie möglich und gibt die erfolgreiche Methode zurück. Zutaten akzeptiert Tandoor über einen schlichten Teil-PATCH. Einheiten lassen sich damit in manchen Tandoor-Versionen NICHT ändern — der Server antwortet mit HTTP 500. Deshalb wird dort zuerst ein vollständiger PUT versucht (anderer Code-Pfad, alle vorhandenen Felder bleiben erhalten, damit z. B. eine hinterlegte Basiseinheit nicht verloren geht), danach PATCH mit Name und zuletzt der schlichte PATCH. Scheitert alles, wirft es den letzten Fehler. """ url = f"api/{endpoint}/{obj['id']}/" if endpoint == "food": client.patch_json(url, {"plural_name": plural}) return "PATCH" voll = dict(obj) voll["plural_name"] = plural versuche = ( ("PUT", lambda: client.put_json(url, voll)), ("PATCH name+plural", lambda: client.patch_json(url, {"name": obj.get("name"), "plural_name": plural})), ("PATCH plural", lambda: client.patch_json(url, {"plural_name": plural})), ) letzter: Exception | None = None for _label, call in versuche: try: call() return _label except TandoorError as exc: letzter = exc raise letzter # type: ignore[misc] def execute(client: TandoorClient, args: argparse.Namespace) -> int: file = Path(args.plan).expanduser().resolve() if not file.is_file(): raise SystemExit(f"Plandatei nicht gefunden: {file}") plan = json.loads(file.read_text(encoding="utf-8")) actions = [a for a in plan.get("actions", []) if a.get("accept")] merges = [a for a in actions if a["action"] == "merge"] set_plurals = [a for a in actions if a["action"] == "set_plural"] deletes = [a for a in actions if a["action"] == "delete"] if not actions: out("Im Plan ist nichts angehakt.") return 0 # Angehakte „ähnliche“ Paare ohne gewählte Richtung sind unvollständig. ohne_richtung = [a for a in merges if not (isinstance(a.get("source_id"), int) and isinstance(a.get("target_id"), int) and a["source_id"] != a["target_id"])] if ohne_richtung: for a in ohne_richtung: paar = f"„{a.get('left_name')}“ ~ „{a.get('right_name')}“" out(f"Übersprungen (keine Richtung gewählt): {paar}") merges = [a for a in merges if a not in ohne_richtung] if not merges and not set_plurals and not deletes: out("Nichts Ausführbares angehakt.") return 0 out(f"Modus: {'AUSFÜHREN' if args.apply else 'TROCKENÜBUNG'}") out(f"Tandoor: {client.base_url}") out(f"{len(merges)} Zusammenführungen · {len(set_plurals)} Plural-Ergänzungen " f"· {len(deletes)} Löschungen") if args.apply: out() out("Zusammenführen und Löschen sind in Tandoor NICHT umkehrbar.") out("Dieses Werkzeug zeichnet vorher auf, was nötig ist, um den") out("Zustand später wieder herzustellen — siehe Reiter „Sicherungen“.") out() index: dict[str, Any] = {"food": {}, "unit": {}, "keyword": {}} if merges: index = reference_index(client) out() run = data_dir() / "laeufe" / stamp() run.mkdir(parents=True, exist_ok=True) (run / "plan.json").write_text( json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") manifest: dict[str, Any] = { "created_at": datetime.now(timezone.utc).isoformat(), "plugin": "tandoor-cleanup", "label": f"Aufräumen ({file.stem})", "tandoor": client.base_url, "mode": "apply" if args.apply else "dry", "plan": file.name, "steps": [], } done, failed = 0, 0 # Erst zusammenführen, dann löschen: Eine Zusammenführung kann einen # Eintrag unbenutzt machen, andersherum wäre die Quelle schon weg. for nummer, action in enumerate(merges, start=1): kind = action["kind"] label = KINDS[kind]["label"] verweise = index.get(kind, {}).get(action["source_id"], []) try: quelle = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/") ziel = client.get_json(f"api/{action['endpoint']}/{action['target_id']}/") except TandoorError as exc: out(f"[{nummer}/{len(merges)}] {label} " f"[{action['source_id']}] → [{action['target_id']}]: nicht lesbar — {exc}") failed += 1 if not args.continue_on_error: return 1 continue source_name = action.get("source_name") or quelle.get("name") target_name = action.get("target_name") or ziel.get("name") prefix = (f"[{nummer}/{len(merges)}] {label}: " f"„{source_name}“ [{action['source_id']}] → " f"„{target_name}“ [{action['target_id']}]") # Plural am Ziel ergänzen? Nur Food, nur wenn Ziel noch keinen hat. will_plural = (kind == "food" and action.get("set_plural") and (action.get("plural_value") or "").strip() and not (ziel.get("plural_name") or "").strip()) plural_value = (action.get("plural_value") or "").strip() step: dict[str, Any] = { "n": nummer, "action": "merge", "kind": kind, "endpoint": action["endpoint"], "origin": action.get("origin", "duplicate"), "source": quelle, "source_id": action["source_id"], "source_name": action["source_name"] or quelle.get("name"), "target_id": action["target_id"], "target_name": action["target_name"] or ziel.get("name"), "target_before": ziel, "references": verweise, "rescued": [], "plural_set": plural_value if will_plural else "", "target_plural_before": ziel.get("plural_name") or "", "restore_level": "teilweise", "status": "geplant", } try: if args.apply: for rolle, obj in (("quelle", quelle), ("ziel", ziel)): (run / f"{kind}-{rolle}-{obj['id']:05d}.json").write_text( json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") note = "" if kind == "food" and not args.ohne_rettung: note, gerettet = rescue_properties( client, action["source_id"], action["target_id"], dry=not args.apply) step["rescued"] = gerettet plural_note = f"Plural „{plural_value}“ ergänzt" if will_plural else "" if not args.apply: out(f"{prefix}: würde zusammenführen" + (f" · {note}" if note else "") + (f" · {len(verweise)} Verweise werden umgehängt" if verweise else "") + (f" · würde {plural_note}" if will_plural else "")) done += 1 continue step["status"] = "angefangen" manifest["steps"].append(step) backups.write_manifest(run, manifest) client.merge(action["endpoint"], action["source_id"], action["target_id"]) client.forget(action["endpoint"]) if will_plural: client.patch_json(f"api/food/{action['target_id']}/", {"plural_name": plural_value}) step["status"] = "done" backups.write_manifest(run, manifest) out(f"{prefix}: ✓ zusammengeführt" + (f" · {note}" if note else "") + (f" · {len(verweise)} Verweise umgehängt" if verweise else "") + (f" · {plural_note}" if will_plural else "")) done += 1 except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") step["status"] = "fehler" step["error"] = str(exc) if args.apply: backups.write_manifest(run, manifest) failed += 1 if not args.continue_on_error: out("Abbruch. Bereits ausgeführte Schritte bleiben bestehen.") return 1 # ---------------------------------------------------- Plurale pflegen plural_kaputt: set[str] = set() # Endpunkte, die Tandoor systematisch ablehnt for nummer, action in enumerate(set_plurals, start=1): endpoint = action.get("endpoint", "food") kind = action.get("kind", "food") obj_id = action["source_id"] wert = (action.get("plural_value") or "").strip() label = KINDS.get(kind, {}).get("label", "Eintrag") prefix = f"[{nummer}/{len(set_plurals)}] Plural {label}: „{action['source_name']}“ [{obj_id}]" if endpoint in plural_kaputt: out(f"{prefix}: übersprungen — {label}-Plurale werden von Tandoor " f"abgelehnt (siehe erste Meldung; bitte in Tandoor selbst setzen)") continue try: obj = client.get_json(f"api/{endpoint}/{obj_id}/") except TandoorError: # Eintrag existiert nicht mehr (z. B. vorher zusammengeführt) — # das ist kein Fehler, nur nichts mehr zu tun. out(f"{prefix}: übersprungen — Eintrag nicht mehr vorhanden") continue aktuell = (obj.get("plural_name") or "").strip() if wert == aktuell: out(f"{prefix}: unverändert („{aktuell}“) — übersprungen") continue hinweis = f"„{aktuell}“ → „{wert}“" if aktuell else f"„{wert}“" if not args.apply: out(f"{prefix}: würde Plural setzen ({hinweis})") done += 1 continue try: methode = _set_plural(client, endpoint, obj, wert) except TandoorError as exc: # Einheiten/Schlagworte: lehnt Tandoor den Schreibzugriff komplett # ab, sind es fast sicher alle — nicht weiter hämmern. if endpoint != "food": plural_kaputt.add(endpoint) out(f"{prefix}: FEHLER — Tandoor akzeptiert {label}-Plurale nicht " f"(alle Schreibwege scheiterten). Weitere {label}-Plurale " f"werden übersprungen; bitte direkt in Tandoor setzen.") out(f" Letzte Serverantwort: {str(exc)[:200]}") else: out(f"{prefix}: FEHLER — {exc}") failed += 1 if not args.continue_on_error: return 1 continue # Erst nach erfolgreichem Schreiben sichern. (run / f"{kind}-plural-{obj_id:05d}.json").write_text( json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") manifest["steps"].append({ "n": len(merges) + nummer, "action": "set_plural", "kind": kind, "endpoint": endpoint, "source_id": obj_id, "source_name": action["source_name"], "plural_before": aktuell, "plural_after": wert, "restore_level": "voll", "status": "done", }) backups.write_manifest(run, manifest) out(f"{prefix}: ✓ Plural gesetzt ({hinweis})" + (f" [{methode}]" if methode not in ("PATCH", "PUT") else "")) done += 1 for nummer, action in enumerate(deletes, start=1): kind = action["kind"] label = KINDS[kind]["label"] prefix = (f"[{nummer}/{len(deletes)}] {label} löschen: " f"„{action['source_name']}“ [{action['source_id']}]") try: obj = client.get_json(f"api/{action['endpoint']}/{action['source_id']}/") step = { "n": len(merges) + len(set_plurals) + nummer, "action": "delete", "kind": kind, "endpoint": action["endpoint"], "source": obj, "source_id": action["source_id"], "source_name": action["source_name"], "references": [], "restore_level": "neue_id", "status": "geplant", } if not args.apply: out(f"{prefix}: würde löschen") done += 1 continue (run / f"geloescht-{kind}-{obj['id']:05d}.json").write_text( json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") step["status"] = "angefangen" manifest["steps"].append(step) backups.write_manifest(run, manifest) client.delete(f"api/{action['endpoint']}/{action['source_id']}/") step["status"] = "done" backups.write_manifest(run, manifest) out(f"{prefix}: ✓ gelöscht") done += 1 except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") failed += 1 if not args.continue_on_error: return 1 if args.apply: backups.write_manifest(run, manifest) out() out("─" * 64) verb = "ausgeführt" if args.apply else "würden ausgeführt" out(f" {done} Schritte {verb} · {failed} Fehler") if args.apply: out(f" Sicherungen: {run}") out(f" Zurückspielen: Reiter „Sicherungen“ oder") out(f" cleanup.py zurueck --lauf {run.name} --apply") else: out(" Nichts verändert. Mit --apply wird es ernst.") out("─" * 64) return 1 if failed else 0 # ---------------------------------------------------------- Zurückspielen def recreate( client: TandoorClient, endpoint: str, payload: dict[str, Any], forbidden_id: int | None, ) -> tuple[dict[str, Any], str]: """ Einen gelöschten Eintrag wieder anlegen. Tücke: Tandoor legt beim POST nicht immer etwas Neues an. Der Quelltext macht ein get_or_create über den Namen — Food/Unit filter(Q(name__iexact=n) | Q(plural_name__iexact=n)) Keyword get_or_create(name=n) (case-sensitiv) — und gibt sonst den vorhandenen Eintrag zurück. Nach einem „Tomaten → Tomate“ ist „Tomaten“ als *Plural* von „Tomate“ belegt: Ein POST „Tomaten“ liefert dann wieder „Tomate“, und die Wiederherstellung wäre stillschweigend wirkungslos. Deshalb: erst anlegen, prüfen was zurückkam, und nur wenn nötig den Umweg über einen freien Zwischennamen gehen. Das Umbenennen per PATCH prüft den Namen nicht — und die Datenbank stört sich nicht daran, weil ihre Eindeutigkeit case-sensitiv ist (Food hat gar keine). """ gewuenscht = (payload.get("name") or "").strip() neu = client.post_json(f"api/{endpoint}/", payload) client.forget(endpoint) passt = (neu.get("id") != forbidden_id and (neu.get("name") or "").strip() == gewuenscht) if passt: return neu, "angelegt" # Tandoor hat einen vorhandenen Eintrag zurückgegeben. Umweg gehen. zwischen = dict(payload) zwischen["name"] = f"{gewuenscht}-wiederherstellung-{secrets.token_hex(3)}" neu = client.post_json(f"api/{endpoint}/", zwischen) client.forget(endpoint) if neu.get("id") == forbidden_id: raise TandoorError( "Tandoor gibt beim Anlegen den Zieleintrag zurück — " "der Name lässt sich nicht wieder belegen." ) try: neu = client.patch_json(f"api/{endpoint}/{neu['id']}/", { "name": gewuenscht, "plural_name": payload.get("plural_name") or "", }) client.forget(endpoint) except TandoorError as exc: # Keine Leiche hinterlassen. try: client.delete(f"api/{endpoint}/{neu['id']}/") except TandoorError: pass raise TandoorError(f"Umbenennen nach dem Anlegen schlug fehl: {exc}") from exc if (neu.get("name") or "").strip() != gewuenscht: raise TandoorError( f"Eintrag heißt nach dem Umbenennen „{neu.get('name')}“ " f"statt „{gewuenscht}“." ) return neu, "angelegt (über Zwischennamen, da der Name belegt war)" def restore(client: TandoorClient, args: argparse.Namespace) -> int: """ Einen Aufräum-Lauf rückgängig machen. Was das kann und was nicht: Gelöschte Einträge werden neu angelegt. Sie bekommen eine neue ID. Da nur unbenutzte Einträge gelöscht werden, zeigte ohnehin nichts auf sie. Zusammengeführte Einträge werden neu angelegt und die aufgezeichneten Rezeptverweise wieder auf sie zurückgehängt. Auch hier: neue ID. Was nicht aufgezeichnet wurde — Einkaufslisten, Einheiten-Umrechnungen, Automatisierungen —, bleibt beim Ziel. Deshalb „teilweise“. """ runs_dir = data_dir() / "laeufe" try: run = backups.resolve_run(runs_dir, args.lauf) except FileNotFoundError as exc: out(str(exc)) return 2 manifest = backups.read_manifest(run) or {} steps = [s for s in manifest.get("steps", []) if s.get("status") == "done"] if not steps: out("In diesem Lauf wurde nichts ausgeführt — nichts zurückzuspielen.") return 0 out(f"Modus: {'ZURÜCKSPIELEN' if args.apply else 'TROCKENÜBUNG'}") out(f"Lauf: {run.name} ({manifest.get('label')})") out(f" vom {manifest.get('created_at')}") out(f"Tandoor: {client.base_url}") if manifest.get("tandoor") and manifest["tandoor"] != client.base_url: out() out(f"ACHTUNG: Der Lauf ging gegen {manifest['tandoor']},") out(f" jetzt eingestellt ist {client.base_url}.") if not args.force: out(" Abbruch. Mit --force trotzdem.") return 2 verlauf = backups.restore_history(run) if verlauf and not args.force: out() out(f"Dieser Lauf wurde bereits am {verlauf[-1]['at']} zurückgespielt.") out("Mit --force noch einmal.") return 2 out(f"{len(steps)} Schritte") out() out("Wichtig: Wiederhergestellte Einträge bekommen eine NEUE ID. Die alte") out("ist in Tandoor vergeben und weg. Rezepte zeigen danach wieder auf den") out("richtigen Eintrag, nur eben unter neuer Nummer.") out() zurueck, fehler = 0, 0 # Rückwärts: der letzte Eingriff wird zuerst rückgängig gemacht. for step in reversed(steps): # Reine Plural-Ergänzung: nur den alten Plural zurückschreiben. if step.get("action") == "set_plural": endpoint = step.get("endpoint", "food") fid = step["source_id"] alt = step.get("plural_before", "") prefix = f"[{step['n']}] Plural „{step['source_name']}“ [{fid}]" if not args.apply: out(f"{prefix}: würde Plural auf „{alt}“ zurücksetzen") zurueck += 1 continue try: obj = client.get_json(f"api/{endpoint}/{fid}/") _set_plural(client, endpoint, obj, alt) out(f"{prefix}: ✓ Plural zurückgesetzt") zurueck += 1 except TandoorError as exc: out(f"{prefix}: FEHLER — {exc}") fehler += 1 continue kind = step["kind"] label = KINDS[kind]["label"] endpoint = step["endpoint"] quelle = step["source"] prefix = f"[{step['n']}] {label} „{step['source_name']}“" # Wurde beim Zusammenführen ein Plural am Ziel ergänzt, muss dieser # ZUERST zurück — sonst gäbe Tandoor beim Anlegen der Quelle (deren Name # jetzt der Plural des Ziels ist) den Zieleintrag zurück. if args.apply and step.get("plural_set") and isinstance(step.get("target_id"), int): try: client.patch_json(f"api/food/{step['target_id']}/", {"plural_name": step.get("target_plural_before", "")}) client.forget(endpoint) except TandoorError: pass # 1. Eintrag wieder anlegen payload: dict[str, Any] = { "name": quelle.get("name"), "plural_name": quelle.get("plural_name") or "", } if quelle.get("description"): payload["description"] = quelle["description"] if kind == "food": props = [] for prop in quelle.get("properties") or []: tid = (prop.get("property_type") or {}).get("id") if isinstance(tid, int): props.append({"property_amount": prop.get("property_amount"), "property_type": {"id": tid}}) if props: payload["properties"] = props if quelle.get("properties_food_amount") is not None: payload["properties_food_amount"] = quelle["properties_food_amount"] pfu = quelle.get("properties_food_unit") if isinstance(pfu, dict) and pfu.get("id"): payload["properties_food_unit"] = {"id": pfu["id"], "name": pfu.get("name")} verweise = step.get("references") or [] if not args.apply: teile = [f"würde {label.lower()} „{quelle.get('name')}“ neu anlegen"] if kind == "food" and payload.get("properties"): teile.append(f"{len(payload['properties'])} Eigenschaften zurück") if verweise: teile.append(f"{len(verweise)} Verweise zurückhängen") out(f"{prefix}: " + " · ".join(teile)) zurueck += 1 continue try: neu, wie = recreate(client, endpoint, payload, step.get("target_id")) except TandoorError as exc: out(f"{prefix}: konnte nicht angelegt werden — {exc}") fehler += 1 continue neue_id = neu.get("id") if neue_id == step["source_id"]: hinweis = f"wieder unter ID {neue_id}" else: hinweis = f"neu unter ID {neue_id} (vorher {step['source_id']})" if "Zwischennamen" in wie: hinweis += ", Name war belegt" # 2. Verweise zurückhängen umgehaengt, verfehlt = 0, 0 for ref in verweise: try: if kind in ("food", "unit"): iid = ref["ingredient_id"] aktuell = None # Schutz: Nur zurückhängen, was tatsächlich noch auf dem # Ziel steht. Wer die Zutat seither von Hand geändert hat, # soll das nicht überschrieben bekommen. recipe = client.get_json(f"api/recipe/{ref['recipe_id']}/") for st in recipe.get("steps") or []: for ing in st.get("ingredients") or []: if ing.get("id") == iid: aktuell = ing if aktuell is None: verfehlt += 1 continue feld = "food" if kind == "food" else "unit" zeigt_auf = (aktuell.get(feld) or {}).get("id") if zeigt_auf != step["target_id"] and not args.force: verfehlt += 1 continue client.patch_json(f"api/ingredient/{iid}/", {feld: {"id": neue_id, "name": neu.get("name")}}) umgehaengt += 1 else: # keyword vorher = ref.get("keywords_before") or [] liste = [{"id": neue_id if k == step["source_id"] else k} for k in vorher] client.patch_json(f"api/recipe/{ref['recipe_id']}/", {"keywords": liste}) umgehaengt += 1 except TandoorError as exc: out(f" Verweis {ref}: {exc}") verfehlt += 1 teile = [f"✓ {hinweis}"] if umgehaengt: teile.append(f"{umgehaengt} Verweise zurückgehängt") if verfehlt: teile.append(f"{verfehlt} übersprungen (zwischenzeitlich geändert)") out(f"{prefix}: " + " · ".join(teile)) zurueck += 1 out() out("─" * 64) verb = "zurückgespielt" if args.apply else "würden zurückgespielt" out(f" {zurueck} Schritte {verb} · {fehler} Fehler") if args.apply: backups.mark_restored(run, {"restored": zurueck, "errors": fehler}) out(" Der Lauf ist als zurückgespielt vermerkt.") out() out(" Was NICHT zurückkommt: Einkaufslisten-Einträge, Einheiten-") out(" Umrechnungen und Automatisierungen, die auf den alten Eintrag") out(" zeigten. Die hat Tandoor beim Zusammenführen aufs Ziel gehängt") out(" und dort bleiben sie.") else: out(" Nichts verändert. Mit --apply wird zurückgespielt.") out("─" * 64) return 1 if fehler else 0 # ----------------------------------------------------------------- Aufruf # ======================================================== KI-Vorauswahl (ChatGPT) PLURAL_BATCH = 40 _KIND_PLURAL = {"food": "Zutaten", "unit": "Einheiten", "keyword": "Schlagworte"} _KIND_SINGULAR = {"food": "Zutat", "unit": "Maßeinheit", "keyword": "Schlagwort"} def _ai_classify_similars(result: dict[str, Any], model: str) -> dict[str, int]: """ KI ordnet jedes ähnliche Paar in drei Töpfe: * **gleich** – zweifelsfrei dasselbe (Schreibvarianten, fehlendes Leerzeichen, Groß/Klein, Singular/Plural). Wird ANGEHAKT und die Richtung vorgewählt (der bleibende Name ist der allgemeinere/korrekt geschriebene). * **unklar** – könnte dasselbe sein, aber nicht sicher. Wird angezeigt, aber nicht vorgewählt — der Mensch entscheidet. * **verschieden** – zweifelsfrei verschiedene Sorten/Produkte. Wird standardmäßig ausgeblendet (per Filter wieder einblendbar). Jedes Paar bekommt ``ai_verdict`` + ``ai_reason``; die Merge-Aktionen der „gleich“-Paare erhalten Richtung und ``accept``. Nichts wird gelöscht. """ by_aid = {a["aid"]: a for a in result["actions"] if a.get("action") == "merge" and a.get("origin") == "similar"} paare = [] for kind, data in result["kinds"].items(): for s in data.get("similar", []): paare.append({ "id": s["aid"], "art": _KIND_PLURAL.get(kind, kind), "a": {"id": s["left"]["id"], "name": s["left"]["name"]}, "b": {"id": s["right"]["id"], "name": s["right"]["name"]}, }) if not paare: return {"geprueft": 0, "gleich": 0, "unklar": 0, "verschieden": 0} system = ( "Du bist ein sorgfältiger Datenpfleger für eine Rezept-Datenbank und " "ordnest jedes Namenspaar in genau eine Kategorie:\n" "• „gleich“ = zweifelsfrei dasselbe: Schreib-/Tippvarianten, fehlendes " "Leerzeichen, Groß-/Kleinschreibung, Singular/Plural oder dieselbe " "Sache nur anders geschrieben.\n" "• „verschieden“ = zweifelsfrei verschiedene Sorten, Typen, Grade oder " "Produkte (z. B. „Weizenmehl 550“ vs. „Weizenmehl 1050“, „Frischkäse“ " "vs. „körniger Frischkäse“, fettreduziert vs. normal, mit/ohne Zusatz " "wie „in Öl“) oder klar verschiedene Zutaten.\n" "• „unklar“ = könnte dasselbe sein, ist aber nicht sicher.\n" "Sei konservativ: „gleich“ nur, wenn es wirklich zweifelsfrei dasselbe " "ist. Bei „gleich“ nenne, welcher Eintrag BLEIBEN soll — der " "allgemeinere bzw. korrekt geschriebene Name. Antworte nur mit JSON." ) user = ( "Gib ein JSON-Objekt zurück: Schlüssel ist die „id“ des Paars, Wert ist " '{"urteil": "gleich"|"unklar"|"verschieden", "keep_id": , "grund": "kurz"}.' "\n\nPaare:\n" + json.dumps(paare, ensure_ascii=False) ) antwort = ai.chat_json( [{"role": "system", "content": system}, {"role": "user", "content": user}], model, ) if not isinstance(antwort, dict): antwort = {} zähler = {"geprueft": len(paare), "gleich": 0, "unklar": 0, "verschieden": 0} for kind, data in result["kinds"].items(): for s in data.get("similar", []): d = antwort.get(s["aid"]) if isinstance(antwort.get(s["aid"]), dict) else {} urteil = d.get("urteil") if urteil not in ("gleich", "unklar", "verschieden"): urteil = "unklar" grund = str(d.get("grund") or "").strip()[:200] s["ai_verdict"] = urteil s["ai_reason"] = grund zähler[urteil] += 1 action = by_aid.get(s["aid"]) if action is None: continue if urteil == "gleich": keep = d.get("keep_id") links = keep == s["left"]["id"] if keep not in (s["left"]["id"], s["right"]["id"]): links = True # Vorgabe: linken (i. d. R. kürzeren) behalten action["target_id"] = s["left"]["id"] if links else s["right"]["id"] action["target_name"] = s["left"]["name"] if links else s["right"]["name"] action["source_id"] = s["right"]["id"] if links else s["left"]["id"] action["source_name"] = s["right"]["name"] if links else s["left"]["name"] action["accept"] = True action["ai_reason"] = grund or "KI: dasselbe" else: action["accept"] = False action["source_id"] = action["target_id"] = None action["source_name"] = action["target_name"] = None action["ai_reason"] = grund action["ai_verdict"] = urteil return zähler def _fill_plurals(actions: list[dict[str, Any]], model: str, alle: bool) -> int: """ Lässt die KI korrekte deutsche Plurale eintragen. Standard: nur dort, wo noch keiner gepflegt ist (manuell Gepflegtes wird nicht überschrieben). Setzt plural_value + accept. Rückgabe: Anzahl vorbelegter Plurale. Einträge, die durch eine angehakte Zusammenführung ohnehin verschwinden, werden übersprungen — ein Plural darauf liefe ins Leere. """ verschwindet = { (a["kind"], a["source_id"]) for a in actions if a["action"] == "merge" and a.get("accept") and isinstance(a.get("source_id"), int) } ziele = [a for a in actions if a["action"] == "set_plural" and (a["kind"], a["source_id"]) not in verschwindet] if not alle: ziele = [a for a in ziele if not (a.get("plural_before_scan") or "").strip()] if not ziele: return 0 system = ( "Du bist Experte für deutsche Grammatik und Lebensmittel. Gib zu jedem " "Namen den korrekten Nominativ Plural. Für Maßeinheiten und nicht " "zählbare Stoffe (z. B. Mehl, Wasser, Öl, Salz, Milliliter, Gramm) ist " "der Plural meist gleich der Einzahl — gib dann den Namen unverändert " "zurück. Behalte Groß-/Kleinschreibung und Zusätze bei. Antworte " "ausschließlich mit JSON." ) gefuellt = 0 for start in range(0, len(ziele), PLURAL_BATCH): batch = ziele[start:start + PLURAL_BATCH] namen = [{"id": a["aid"], "art": _KIND_SINGULAR.get(a["kind"], a["kind"]), "name": a["source_name"]} for a in batch] user = ( "Gib ein JSON-Objekt zurück: Schlüssel ist die „id“, Wert ist der " "Plural als String.\n\nEinträge:\n" + json.dumps(namen, ensure_ascii=False) ) antwort = ai.chat_json( [{"role": "system", "content": system}, {"role": "user", "content": user}], model, ) if not isinstance(antwort, dict): continue for a in batch: pl = antwort.get(a["aid"]) if isinstance(pl, str) and pl.strip(): a["plural_value"] = pl.strip()[:120] a["ai"] = True # Nur anhaken, wenn es sich vom Ist-Stand unterscheidet. if a["plural_value"] != (a.get("plural_before_scan") or "").strip(): a["accept"] = True gefuellt += 1 return gefuellt def vorbelegen(args: argparse.Namespace) -> int: """KI schlägt Plurale für Zutaten und Einheiten vor (nur Vorbelegung).""" file = Path(args.plan).expanduser().resolve() if not file.is_file(): raise SystemExit(f"Plandatei nicht gefunden: {file}") plan = json.loads(file.read_text(encoding="utf-8")) actions = plan.get("actions", []) out(f"KI-Plurale mit Modell {args.model} …") gefuellt = _fill_plurals(actions, args.model, alle=args.alle_plurale) out(f" {gefuellt} Plurale vorgeschlagen" + (" (auch bestehende)" if args.alle_plurale else " (nur fehlende)")) plan["ai_prefilled_at"] = datetime.now(timezone.utc).isoformat() file.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") out() out(f"Plan aktualisiert: {file}") out("Die Vorschläge bitte prüfen, bevor du ausführst.") return 0 def main() -> int: parser = argparse.ArgumentParser(description="Stammdaten aufräumen") parser.add_argument("--base-url", default=None) parser.add_argument("--token", default=None) parser.add_argument("--auth-scheme", default=None) parser.add_argument("--timeout", type=float, default=None) parser.add_argument("--insecure", action="store_true") sub = parser.add_subparsers(dest="command", required=True) p = sub.add_parser("pruefen", help="Dubletten und Unbenutztes suchen") p.add_argument("--arten", default="", help="food,unit,keyword — leer = alle") p.add_argument("--ki-filter", action="store_true", help="ähnliche Paare per KI vorfiltern (definitiv verschiedene raus)") p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) p = sub.add_parser("ausfuehren", help="Plan abarbeiten") p.add_argument("--plan", required=True) p.add_argument("--apply", action="store_true", help="wirklich ausführen") p.add_argument("--ohne-rettung", action="store_true", help="Nährwerte der Quelle NICHT vorher ans Ziel retten") p.add_argument("--continue-on-error", action="store_true") p = sub.add_parser("zurueck", help="Einen Lauf zurückspielen") p.add_argument("--lauf", required=True, help="Ordnername unter laeufe/") p.add_argument("--apply", action="store_true", help="wirklich zurückspielen") p.add_argument("--force", action="store_true", help="auch bei zwischenzeitlichen Änderungen oder anderem Tandoor") p = sub.add_parser("vorbelegen", help="KI trifft eine Vorauswahl (ChatGPT)") p.add_argument("--plan", required=True) p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) p.add_argument("--alle-plurale", action="store_true", help="auch bestehende Plurale neu vorschlagen (Vorsicht: überschreibt)") p = sub.add_parser("probe", help="OpenAI-Verbindung testen") p.add_argument("--model", default=os.environ.get("OPENAI_MODEL", "gpt-5.5")) args = parser.parse_args() # Diese Befehle brauchen kein Tandoor — nur OpenAI bzw. eine Plandatei. if args.command == "probe": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 ok, meldung = ai.probe(args.model) out(meldung) return 0 if ok else 1 if args.command == "vorbelegen": if not os.environ.get("OPENAI_API_KEY"): out("OPENAI_API_KEY fehlt — siehe Einstellungen.") return 2 return vorbelegen(args) try: client = TandoorClient.from_env( base_url=args.base_url, token=args.token, auth_scheme=args.auth_scheme, timeout=args.timeout, verify=False if args.insecure else None, ) except TandoorError as exc: out(f"Tandoor-Zugang fehlt: {exc}") return 2 try: if args.command == "pruefen": plan = scan(client, args) target = data_dir() / "plaene" / f"{stamp()}.json" target.parent.mkdir(parents=True, exist_ok=True) target.write_text( json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print_scan(plan) out() out(f"Plan: {target}") out("Es wurde nichts verändert.") return 0 if args.command == "ausfuehren": return execute(client, args) if args.command == "zurueck": return restore(client, args) except TandoorError as exc: out(f"Tandoor meldet: {exc}") return 1 except KeyboardInterrupt: out("Abgebrochen.") return 130 return 0 if __name__ == "__main__": raise SystemExit(main())