"""Löst Regal-, Buch- und Kapitelnamen in IDs auf und legt Fehlendes auf Wunsch an. Ersteller: Pascal Michel Erstellt: 2026-08-05 Doku: docs/code/struktur.md · Version und Änderungen: CHANGELOG.md """ 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 # 0: Buch ist im Probelauf nur geplant kapitel_id: int | None = None # 0: Kapitel nur geplant, None: kein Kapitel angelegt: list[str] = field(default_factory=list) def _passt(eintrag: dict, name: str) -> bool: return str(eintrag.get("name", "")).strip().casefold() == name.strip().casefold() def _geplant_schluessel(sammlung: str, *namen: str) -> tuple[str, ...]: """Schlüssel für das Probelauf-Gedächtnis; Namen vergleicht er wie _passt.""" return (sammlung, *(name.strip().casefold() for name in namen)) 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]] = {} # Probelauf: schon als „wird angelegt“ gemeldete Regale, Bücher und Kapitel. self._geplant: set[tuple[str, ...]] = set() 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 _plane( self, schluessel: tuple[str, ...], art: str, name: str, angelegt: list[str] ) -> None: """Probelauf: meldet eine geplante Neuanlage nur beim ersten Mal.""" if schluessel in self._geplant: return self._geplant.add(schluessel) angelegt.append(self._vermerke(art, name)) 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) if self._nur_planen: self._plane( _geplant_schluessel("shelves", regal_name), "Regal", regal_name, angelegt ) return angelegt.append(self._vermerke("Regal", regal_name)) 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) if self._nur_planen: self._plane(_geplant_schluessel("books", buch), "Buch", buch, angelegt) gefunden = {"id": 0} else: angelegt.append(self._vermerke("Buch", buch)) 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) if self._nur_planen: self._plane( _geplant_schluessel("chapters", buch, kapitel), "Kapitel", kapitel, angelegt ) treffer = {"id": 0} else: angelegt.append(self._vermerke("Kapitel", kapitel)) 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)