.vault-tools (ohne trading-plattform-pruefung und watermarks-remover), .claude/agents, .claude/skills, .claude/workflows, .claude/settings.json. Co-Authored-By: Claude Opus 5.5 <[email protected]>
185 lines
7.4 KiB
Python
185 lines
7.4 KiB
Python
"""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)
|