Files
claude-regelwerk/.claude/skills/bookstack-upload/scripts/bookstack/struktur.py
T
Pascal MichelandClaude Opus 5 c064c729ed Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 22:38:15 +02:00

157 lines
6.1 KiB
Python

"""Löst Regal-, Buch- und Kapitelnamen in IDs auf und legt Fehlendes auf Wunsch an."""
from __future__ import annotations
from dataclasses import dataclass, field
from bookstack.api import BookStackApi
class StrukturFehler(Exception):
"""Ein benanntes Regal, Buch oder Kapitel existiert nicht (und darf nicht angelegt werden)."""
@dataclass
class Ziel:
buch_id: int
kapitel_id: int | None = None
angelegt: list[str] = field(default_factory=list)
def _passt(eintrag: dict, name: str) -> bool:
return str(eintrag.get("name", "")).strip().casefold() == name.strip().casefold()
class Strukturaufloeser:
def __init__(
self,
api: BookStackApi,
*,
anlegen_erlaubt: bool = False,
nur_planen: bool = False,
) -> None:
self._api = api
self._anlegen_erlaubt = anlegen_erlaubt
# Im Probelauf darf nichts entstehen. Fehlendes wird nur benannt.
self._nur_planen = nur_planen
self._speicher: dict[str, list[dict]] = {}
def _sammlung(self, name: str) -> list[dict]:
if name not in self._speicher:
self._speicher[name] = self._api.liste(f"/api/{name}")
return self._speicher[name]
def _verwerfe(self, name: str) -> None:
self._speicher.pop(name, None)
def _finde(self, sammlung: str, name: str, **filter_felder) -> dict | None:
for eintrag in self._sammlung(sammlung):
if _passt(eintrag, name) and all(
eintrag.get(feld) == wert for feld, wert in filter_felder.items()
):
return eintrag
return None
def _fehlt(self, art: str, name: str) -> StrukturFehler:
return StrukturFehler(
f"{art} „{name}“ existiert in BookStack nicht. "
"Namen im Frontmatter prüfen oder mit --struktur-anlegen erneut starten."
)
def _vermerke(self, art: str, name: str) -> str:
"""Beschreibt eine Neuanlage — im Probelauf als Absicht, sonst als Tatsache."""
zusatz = " (wird angelegt)" if self._nur_planen else ""
return f'{art} "{name.strip()}"{zusatz}'
def _haenge_an_regal(self, regal_name: str, buch_id: int, angelegt: list[str]) -> None:
regal = self._finde("shelves", regal_name)
if regal is None:
if not self._anlegen_erlaubt:
raise self._fehlt("Regal", regal_name)
angelegt.append(self._vermerke("Regal", regal_name))
if self._nur_planen:
return
self._api.anfrage(
"POST", "/api/shelves", {"name": regal_name.strip(), "books": [buch_id]}
)
self._verwerfe("shelves")
return
vollstaendig = self._api.anfrage("GET", f"/api/shelves/{regal['id']}")
# Lesen liefert Buchobjekte, Schreiben erwartet reine IDs. Beide Formen dulden.
vorhandene = [
int(eintrag["id"] if isinstance(eintrag, dict) else eintrag)
for eintrag in vollstaendig.get("books", [])
]
if buch_id in vorhandene or self._nur_planen:
return
self._api.anfrage(
"PUT", f"/api/shelves/{regal['id']}", {"books": vorhandene + [buch_id]}
)
def aufloesen(self, regal: str | None, buch: str, kapitel: str | None) -> Ziel:
if not buch or not buch.strip():
raise StrukturFehler("Feld „bookstack_book“ fehlt oder ist leer.")
angelegt: list[str] = []
gefunden = self._finde("books", buch)
if gefunden is None:
if not self._anlegen_erlaubt:
raise self._fehlt("Buch", buch)
angelegt.append(self._vermerke("Buch", buch))
if self._nur_planen:
gefunden = {"id": 0}
else:
gefunden = self._api.anfrage("POST", "/api/books", {"name": buch.strip()})
self._verwerfe("books")
buch_id = int(gefunden["id"])
if regal and regal.strip():
self._haenge_an_regal(regal, buch_id, angelegt)
kapitel_id: int | None = None
if kapitel and kapitel.strip():
treffer = self._finde("chapters", kapitel, book_id=buch_id)
if treffer is None:
if not self._anlegen_erlaubt:
raise self._fehlt("Kapitel", kapitel)
angelegt.append(self._vermerke("Kapitel", kapitel))
if self._nur_planen:
treffer = {"id": 0}
else:
treffer = self._api.anfrage(
"POST", "/api/chapters", {"book_id": buch_id, "name": kapitel.strip()}
)
self._verwerfe("chapters")
kapitel_id = int(treffer["id"])
return Ziel(buch_id=buch_id, kapitel_id=kapitel_id, angelegt=angelegt)
def baum(self) -> str:
"""Menschenlesbarer Überblick über die vorhandene Struktur."""
buecher = {int(eintrag["id"]): eintrag for eintrag in self._sammlung("books")}
kapitel: dict[int, list[str]] = {}
for eintrag in self._sammlung("chapters"):
kapitel.setdefault(int(eintrag.get("book_id", 0)), []).append(str(eintrag["name"]))
zeilen: list[str] = []
zugeordnet: set[int] = set()
for regal in self._sammlung("shelves"):
zeilen.append(f"Regal: {regal['name']}")
vollstaendig = self._api.anfrage("GET", f"/api/shelves/{regal['id']}")
for eintrag in vollstaendig.get("books", []):
buch_id = int(eintrag["id"])
zugeordnet.add(buch_id)
zeilen.append(f" Buch: {buecher.get(buch_id, eintrag)['name']}")
zeilen.extend(f" Kapitel: {name}" for name in kapitel.get(buch_id, []))
frei = [wert for schluessel, wert in buecher.items() if schluessel not in zugeordnet]
if frei:
zeilen.append("Ohne Regal:")
for eintrag in frei:
zeilen.append(f" Buch: {eintrag['name']}")
zeilen.extend(
f" Kapitel: {name}" for name in kapitel.get(int(eintrag["id"]), [])
)
return "\n".join(zeilen)