From dc3dcb016d5dfaca2ed08e906054134528c05f1f Mon Sep 17 00:00:00 2001 From: H5N3RG Date: Fri, 6 Mar 2026 12:05:55 +0000 Subject: [PATCH] Dateien nach "/" hochladen --- bookstack_connector.py | 1078 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1078 insertions(+) create mode 100644 bookstack_connector.py diff --git a/bookstack_connector.py b/bookstack_connector.py new file mode 100644 index 0000000..8546b71 --- /dev/null +++ b/bookstack_connector.py @@ -0,0 +1,1078 @@ +""" +title: BookStack Connector +description: Connects to a BookStack wiki server via its REST API. Allows the LLM to list, read, create, edit and delete shelves, books, chapters and pages. All capabilities can be individually enabled/disabled by an admin via Valves. +author: Claude / Anthropic +author_url: https://claude.ai +version: 1.3.0 +license: MIT +requirements: requests +""" + +import re +import logging +import requests +from typing import Any, Callable, Optional +from pydantic import BaseModel, Field + +# Logging-Setup: schreibt in den OWUI-Server-Log (podman logs open-webui) +logging.basicConfig(level=logging.DEBUG) +log = logging.getLogger("BookStackConnector") + + +# ───────────────────────────────────────────── +# Helper: EventEmitter wrapper +# ───────────────────────────────────────────── +class EventEmitter: + def __init__(self, event_emitter: Callable[[dict], Any] = None): + self.event_emitter = event_emitter + + async def status(self, description: str, done: bool = False): + if self.event_emitter: + await self.event_emitter( + { + "type": "status", + "data": {"description": description, "done": done}, + } + ) + + async def error(self, description: str): + if self.event_emitter: + await self.event_emitter( + { + "type": "status", + "data": {"description": f"❌ {description}", "done": True}, + } + ) + + +# ───────────────────────────────────────────── +# Helper: BookStack API client +# ───────────────────────────────────────────── +class BookStackAPI: + def __init__(self, base_url: str, token_id: str, token_secret: str): + self.base_url = base_url.rstrip("/") + self.headers = { + "Authorization": f"Token {token_id}:{token_secret}", + "Content-Type": "application/json", + } + + def _parse_response(self, resp: requests.Response) -> dict: + """Parst die API-Antwort mit ausführlichem Logging bei Fehlern.""" + log.debug( + "[BookStack] %s %s → HTTP %s | Content-Type: %s | Body[:300]: %s", + resp.request.method, + resp.url, + resp.status_code, + resp.headers.get("Content-Type", "?"), + resp.text[:300].replace("\n", " "), + ) + resp.raise_for_status() + content_type = resp.headers.get("Content-Type", "") + if "application/json" not in content_type: + log.error( + "[BookStack] Antwort ist kein JSON! Content-Type=%s Body[:500]=%s", + content_type, + resp.text[:500], + ) + raise ValueError( + f"BookStack antwortete nicht mit JSON.\n" + f"Content-Type: {content_type}\n" + f"Antwort (Anfang): {resp.text[:300]}\n\n" + f"Mögliche Ursachen:\n" + f" • BOOKSTACK_URL falsch oder nicht erreichbar\n" + f" • API antwortet mit Login-Seite (HTML) → Token ungültig\n" + f" • SSL-Fehler oder Redirect\n" + f" • URL endet mit '/' → doppeltes '/api/'" + ) + return resp.json() + + def _get(self, endpoint: str, params: dict = None) -> dict: + url = f"{self.base_url}/api/{endpoint}" + log.debug("[BookStack] GET %s params=%s", url, params) + resp = requests.get(url, headers=self.headers, params=params, timeout=15) + return self._parse_response(resp) + + def _post(self, endpoint: str, data: dict) -> dict: + url = f"{self.base_url}/api/{endpoint}" + log.debug("[BookStack] POST %s payload=%s", url, data) + resp = requests.post(url, headers=self.headers, json=data, timeout=15) + return self._parse_response(resp) + + def _put(self, endpoint: str, data: dict) -> dict: + url = f"{self.base_url}/api/{endpoint}" + log.debug("[BookStack] PUT %s payload=%s", url, data) + resp = requests.put(url, headers=self.headers, json=data, timeout=15) + return self._parse_response(resp) + + def _delete(self, endpoint: str) -> bool: + url = f"{self.base_url}/api/{endpoint}" + log.debug("[BookStack] DELETE %s", url) + resp = requests.delete(url, headers=self.headers, timeout=15) + log.debug("[BookStack] DELETE → HTTP %s", resp.status_code) + resp.raise_for_status() + return True + + def list_all(self, endpoint: str, count: int = 100) -> list: + """Fetch all items with automatic pagination.""" + items = [] + offset = 0 + while True: + data = self._get(endpoint, params={"count": count, "offset": offset}) + batch = data.get("data", []) + items.extend(batch) + total = data.get("total", len(items)) + offset += len(batch) + if offset >= total or not batch: + break + return items + + +# ───────────────────────────────────────────── +# Main Tool class +# ───────────────────────────────────────────── +class Tools: + + # ── Admin-Valves ────────────────────────── + class Valves(BaseModel): + # --- Authentication --- + BOOKSTACK_URL: str = Field( + default="", + description="Base URL deiner BookStack-Instanz, z.B. https://wiki.example.com", + ) + API_TOKEN_ID: str = Field( + default="", + description="BookStack API Token ID (Profil → API Tokens)", + ) + API_TOKEN_SECRET: str = Field( + default="", + description="BookStack API Token Secret", + ) + MAX_ITEMS: int = Field( + default=100, + description="Maximale Anzahl Items pro API-Request (Pagination)", + ) + + # --- Feature Flags (Lesen) --- + ENABLE_LIST_STRUCTURE: bool = Field( + default=True, + description="✅ Erlauben: Gesamte Bibliotheksstruktur anzeigen (Shelves → Books → Chapters → Pages)", + ) + ENABLE_READ_PAGE: bool = Field( + default=True, + description="✅ Erlauben: Seiteninhalte lesen (Markdown + HTML)", + ) + ENABLE_SEARCH: bool = Field( + default=True, + description="✅ Erlauben: Inhalte durchsuchen", + ) + + # --- Feature Flags (Schreiben: Seiten) --- + ENABLE_CREATE_PAGE: bool = Field( + default=False, + description="⚠️ Erlauben: Neue Seiten erstellen", + ) + ENABLE_EDIT_PAGE: bool = Field( + default=False, + description="⚠️ Erlauben: Bestehende Seiten bearbeiten", + ) + ENABLE_DELETE_PAGE: bool = Field( + default=False, + description="🔴 Erlauben: Seiten löschen (UNWIDERRUFLICH!)", + ) + + # --- Feature Flags (Schreiben: Bücher & Kapitel) --- + ENABLE_CREATE_BOOK: bool = Field( + default=False, + description="⚠️ Erlauben: Neue Bücher erstellen", + ) + ENABLE_EDIT_BOOK: bool = Field( + default=False, + description="⚠️ Erlauben: Bücher bearbeiten", + ) + ENABLE_CREATE_CHAPTER: bool = Field( + default=False, + description="⚠️ Erlauben: Neue Kapitel erstellen", + ) + ENABLE_EDIT_CHAPTER: bool = Field( + default=False, + description="⚠️ Erlauben: Kapitel bearbeiten", + ) + + def __init__(self): + """ + Pflicht-Initialisierung: OWUI braucht self.valves = self.Valves() im __init__. + OWUI setzt danach die gespeicherten Admin-Werte auf self.valves – aber nur + wenn das Attribut bereits existiert. Ohne __init__ → 'has no attribute valves'. + """ + self.valves = self.Valves() + + def _api(self) -> BookStackAPI: + return BookStackAPI( + self.valves.BOOKSTACK_URL, + self.valves.API_TOKEN_ID, + self.valves.API_TOKEN_SECRET, + ) + + def _check_config(self) -> Optional[str]: + """Prüft ob Auth-Daten gesetzt sind. Gibt Fehlermeldung zurück oder None.""" + tid = self.valves.API_TOKEN_ID + log.debug( + "[BookStack] Config-Check → URL='%s' | TOKEN_ID='%s' | TOKEN_SECRET='%s'", + self.valves.BOOKSTACK_URL, + (tid[:4] + "***") if tid else "(leer)", + "***" if self.valves.API_TOKEN_SECRET else "(leer)", + ) + if not self.valves.BOOKSTACK_URL: + return "❌ Bitte BOOKSTACK_URL in den Valve-Einstellungen eintragen." + if not self.valves.API_TOKEN_ID or not self.valves.API_TOKEN_SECRET: + return "❌ Bitte API_TOKEN_ID und API_TOKEN_SECRET in den Valve-Einstellungen eintragen." + return None + + # ───────────────────────────────────────── + # TOOL 1 – Gesamtstruktur anzeigen + # ───────────────────────────────────────── + async def get_library_structure( + self, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Lists the complete BookStack library structure as a hierarchical overview. + Shows all Shelves, Books (with their Shelf assignment), Chapters and Pages + including their IDs. IDs are required for all read/edit/delete operations. + Use this as your starting point to navigate the wiki. + + :return: A formatted Markdown tree of the entire library with IDs. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_LIST_STRUCTURE: + return "❌ Die Funktion 'Bibliotheksstruktur anzeigen' ist vom Administrator deaktiviert." + + err = self._check_config() + if err: + return err + + try: + api = self._api() + + await emitter.status("📚 Lade Shelves...") + shelves = api.list_all("shelves", self.valves.MAX_ITEMS) + + await emitter.status("📖 Lade Bücher...") + books = api.list_all("books", self.valves.MAX_ITEMS) + + await emitter.status("📑 Lade Kapitel...") + chapters = api.list_all("chapters", self.valves.MAX_ITEMS) + + await emitter.status("📄 Lade Seiten...") + pages = api.list_all("pages", self.valves.MAX_ITEMS) + + await emitter.status("🗂️ Baue Strukturbaum...") + + # Shelf → Book Mapping via Shelf-Detailendpunkt + shelf_book_map: dict = {s["id"]: [] for s in shelves} + for shelf in shelves: + detail = api._get(f"shelves/{shelf['id']}") + for b in detail.get("books", []): + shelf_book_map[shelf["id"]].append(b["id"]) + + book_map: dict = {b["id"]: b for b in books} + + chapter_map: dict = {} + for ch in chapters: + chapter_map.setdefault(ch.get("book_id"), []).append(ch) + + page_map_book: dict = {} + page_map_chapter: dict = {} + for p in pages: + if p.get("chapter_id"): + page_map_chapter.setdefault(p["chapter_id"], []).append(p) + else: + page_map_book.setdefault(p.get("book_id"), []).append(p) + + shelved_book_ids = set( + bid for bids in shelf_book_map.values() for bid in bids + ) + + lines = [] + lines.append("# 📚 BookStack Bibliotheksstruktur\n") + lines.append( + f"> **{len(shelves)} Regal(e)** · **{len(books)} Buch/Bücher** · " + f"**{len(chapters)} Kapitel** · **{len(pages)} Seite(n)**\n" + ) + lines.append("---\n") + + def render_book(book_id: int, indent: str = " "): + b = book_map.get(book_id) + if not b: + return + lines.append(f"{indent}📖 **{b['name']}** `[Book ID: {b['id']}]`") + for p in page_map_book.get(book_id, []): + lines.append(f"{indent} 📄 {p['name']} `[Page ID: {p['id']}]`") + for ch in chapter_map.get(book_id, []): + lines.append( + f"{indent} 📑 **{ch['name']}** `[Chapter ID: {ch['id']}]`" + ) + for p in page_map_chapter.get(ch["id"], []): + lines.append( + f"{indent} 📄 {p['name']} `[Page ID: {p['id']}]`" + ) + + for shelf in shelves: + lines.append( + f"\n🗄️ **REGAL: {shelf['name']}** `[Shelf ID: {shelf['id']}]`" + ) + if shelf.get("description"): + lines.append(f" *{shelf['description']}*") + book_ids = shelf_book_map.get(shelf["id"], []) + if not book_ids: + lines.append(" *(keine Bücher)*") + for bid in book_ids: + render_book(bid) + + unshelved = [b for b in books if b["id"] not in shelved_book_ids] + if unshelved: + lines.append("\n📦 **BÜCHER OHNE REGAL**") + for b in unshelved: + render_book(b["id"]) + + lines.append("\n---") + lines.append( + "*Verwende die IDs für Lese-, Bearbeitungs- und Löschoperationen.*" + ) + + await emitter.status("✅ Struktur geladen.", done=True) + return "\n".join(lines) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 2 – Seite lesen + # ───────────────────────────────────────── + async def get_page_content( + self, + page_id: int, + format: str = "markdown", + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Reads the full content of a specific BookStack page by its ID. + Get page IDs from get_library_structure() or search_content(). + + :param page_id: The numeric ID of the page to read. + :param format: Content format – 'markdown', 'html', or 'both'. Default: 'markdown'. + :return: The page title and its content in the requested format. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_READ_PAGE: + return "❌ Die Funktion 'Seite lesen' ist vom Administrator deaktiviert." + err = self._check_config() + if err: + return err + + try: + api = self._api() + await emitter.status(f"📄 Lade Seite {page_id}...") + data = api._get(f"pages/{int(page_id)}") + + title = data.get("name", "Unbekannt") + md = data.get("markdown", "") + html = data.get("html", "") + updated = data.get("updated_at", "") + editor = data.get("editor", "") + + lines = [ + f"# 📄 {title}", + f"**Page ID:** `{page_id}` | **Book ID:** `{data.get('book_id')}` | " + f"**Chapter ID:** `{data.get('chapter_id') or '—'}` | " + f"**Zuletzt geändert:** {updated} | **Editor:** {editor}", + "", + ] + + fmt = format.lower() + if fmt in ("markdown", "both"): + lines.append("## Inhalt (Markdown)") + lines.append(md if md else "*Kein Markdown-Inhalt verfügbar.*") + if fmt in ("html", "both"): + lines.append("\n## Inhalt (HTML)") + lines.append( + f"```html\n{html}\n```" if html else "*Kein HTML-Inhalt verfügbar.*" + ) + + await emitter.status("✅ Seite geladen.", done=True) + return "\n".join(lines) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 3 – Suche + # ───────────────────────────────────────── + async def search_content( + self, + query: str, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Searches across all BookStack content (pages, chapters, books, shelves). + Returns matching items with their IDs, types and a short preview. + + :param query: The search string. + :return: A list of matching content items with IDs and types. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_SEARCH: + return "❌ Die Funktion 'Suche' ist vom Administrator deaktiviert." + err = self._check_config() + if err: + return err + + try: + api = self._api() + await emitter.status(f"🔍 Suche nach: '{query}'...") + data = api._get("search", params={"query": query, "count": 30}) + results = data.get("data", []) + + if not results: + await emitter.status( + "✅ Suche abgeschlossen – keine Ergebnisse.", done=True + ) + return f"🔍 Keine Ergebnisse für **'{query}'**." + + type_icons = { + "page": "📄", + "chapter": "📑", + "book": "📖", + "bookshelf": "🗄️", + } + lines = [ + f"# 🔍 Suchergebnisse für: '{query}'\n", + f"**{len(results)} Ergebnis(se):**\n", + ] + + for item in results: + icon = type_icons.get(item.get("type", ""), "•") + name = item.get("name", "Unbekannt") + itype = item.get("type", "?") + iid = item.get("id", "?") + preview = item.get("preview_html", {}).get("content", "") + preview_clean = re.sub(r"<[^>]+>", "", preview)[:200] + lines.append(f"{icon} **{name}** `[{itype.capitalize()} ID: {iid}]`") + if preview_clean: + lines.append(f" > {preview_clean}…") + lines.append("") + + await emitter.status("✅ Suche abgeschlossen.", done=True) + return "\n".join(lines) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 4 – Seite erstellen + # ───────────────────────────────────────── + async def create_page( + self, + book_id: int, + title: str, + content: str, + content_format: str = "markdown", + chapter_id: Optional[int] = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Creates a new page in a BookStack book, optionally inside a chapter. + Get book_id and chapter_id from get_library_structure(). + + :param book_id: The ID of the target book. + :param title: Title of the new page. + :param content: The page content as a string. + :param content_format: Format of the content – 'markdown' or 'html'. Default: 'markdown'. + :param chapter_id: Optional chapter ID. Omit to place page at book level. + :return: Confirmation with the new page's ID. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_CREATE_PAGE: + return ( + "❌ Die Funktion 'Seite erstellen' ist vom Administrator deaktiviert." + ) + err = self._check_config() + if err: + return err + + try: + api = self._api() + await emitter.status(f"✏️ Erstelle Seite '{title}'...") + + payload: dict = {"book_id": int(book_id), "name": title} + if chapter_id: + payload["chapter_id"] = int(chapter_id) + if content_format.lower() == "html": + payload["html"] = content + else: + payload["markdown"] = content + + result = api._post("pages", payload) + page_id = result.get("id") + + await emitter.status("✅ Seite erstellt.", done=True) + return ( + f"✅ **Seite erfolgreich erstellt!**\n" + f"- **Titel:** {title}\n" + f"- **Page ID:** `{page_id}`\n" + f"- **Book ID:** `{book_id}`\n" + f"- **Chapter ID:** `{chapter_id or '—'}`" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 5 – Seite bearbeiten + # ───────────────────────────────────────── + async def update_page( + self, + page_id: int, + title: Optional[str] = None, + content: Optional[str] = None, + content_format: str = "markdown", + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Updates an existing page in BookStack. You can update the title, the content, or both. + Always fetch current content with get_page_content() first to avoid data loss. + + :param page_id: The numeric ID of the page to update. + :param title: Optional new title. Omit to keep the current title. + :param content: Optional new content string. Omit to keep the current content. + :param content_format: Format of the content – 'markdown' or 'html'. Default: 'markdown'. + :return: Confirmation of the update. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_EDIT_PAGE: + return ( + "❌ Die Funktion 'Seite bearbeiten' ist vom Administrator deaktiviert." + ) + err = self._check_config() + if err: + return err + + if title is None and content is None: + return "❌ Bitte mindestens 'title' oder 'content' angeben." + + try: + api = self._api() + await emitter.status(f"📝 Aktualisiere Seite {page_id}...") + + current = api._get(f"pages/{int(page_id)}") + payload: dict = { + "book_id": int(current["book_id"]), + "name": title if title is not None else current["name"], + } + if content is not None: + if content_format.lower() == "html": + payload["html"] = content + else: + payload["markdown"] = content + else: + payload["markdown"] = current.get("markdown", "") + + result = api._put(f"pages/{page_id}", payload) + + await emitter.status("✅ Seite aktualisiert.", done=True) + return ( + f"✅ **Seite erfolgreich aktualisiert!**\n" + f"- **Page ID:** `{page_id}`\n" + f"- **Neuer Titel:** {result.get('name')}\n" + f"- **Zuletzt geändert:** {result.get('updated_at')}" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 6 – Seite löschen + # ───────────────────────────────────────── + async def delete_page( + self, + page_id: int, + confirm: bool = False, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Permanently deletes a page from BookStack. THIS ACTION IS IRREVERSIBLE. + ALWAYS ask the user to confirm before calling this. Only proceed with confirm=True. + + :param page_id: The numeric ID of the page to delete. + :param confirm: Must be True to confirm deletion. Default: False (safety guard). + :return: Confirmation or cancellation message. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_DELETE_PAGE: + return "❌ Die Funktion 'Seite löschen' ist vom Administrator deaktiviert." + err = self._check_config() + if err: + return err + + if not confirm: + return ( + f"⚠️ **Löschung nicht bestätigt.**\n" + f"Du bist dabei, **Page ID `{page_id}`** permanent zu löschen.\n" + "Diese Aktion **kann nicht rückgängig gemacht werden**. " + "Bitte bestätige mit `confirm=True`." + ) + + try: + api = self._api() + try: + page_info = api._get(f"pages/{int(page_id)}") + page_name = page_info.get("name", "Unbekannt") + except Exception: + page_name = "Unbekannt" + + await emitter.status(f"🗑️ Lösche Seite '{page_name}' ({page_id})...") + api._delete(f"pages/{int(page_id)}") + + await emitter.status("✅ Seite gelöscht.", done=True) + return f"✅ **Seite gelöscht:** '{page_name}' (ID: `{page_id}`) wurde permanent entfernt." + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 7 – Buch erstellen + # ───────────────────────────────────────── + async def create_book( + self, + name: str, + description: str = "", + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Creates a new book in BookStack. + + :param name: Name/title of the new book. + :param description: Optional short description. + :return: Confirmation with the new book's ID. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_CREATE_BOOK: + return "❌ Die Funktion 'Buch erstellen' ist vom Administrator deaktiviert." + err = self._check_config() + if err: + return err + + try: + api = self._api() + await emitter.status(f"📖 Erstelle Buch '{name}'...") + result = api._post("books", {"name": name, "description": description}) + + await emitter.status("✅ Buch erstellt.", done=True) + return ( + f"✅ **Buch erfolgreich erstellt!**\n" + f"- **Name:** {name}\n" + f"- **Book ID:** `{result.get('id')}`\n" + f"- **Beschreibung:** {description or '—'}" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 8 – Buch bearbeiten + # ───────────────────────────────────────── + async def update_book( + self, + book_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Updates the name and/or description of an existing book. + Get book IDs from get_library_structure(). + + :param book_id: The numeric ID of the book to update. + :param name: Optional new name. + :param description: Optional new description. + :return: Confirmation of the update. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_EDIT_BOOK: + return ( + "❌ Die Funktion 'Buch bearbeiten' ist vom Administrator deaktiviert." + ) + err = self._check_config() + if err: + return err + + if name is None and description is None: + return "❌ Bitte mindestens 'name' oder 'description' angeben." + + try: + api = self._api() + await emitter.status(f"📝 Aktualisiere Buch {book_id}...") + current = api._get(f"books/{int(book_id)}") + payload = { + "name": name if name is not None else current["name"], + "description": ( + description + if description is not None + else current.get("description", "") + ), + } + result = api._put(f"books/{book_id}", payload) + + await emitter.status("✅ Buch aktualisiert.", done=True) + return ( + f"✅ **Buch erfolgreich aktualisiert!**\n" + f"- **Book ID:** `{book_id}`\n" + f"- **Neuer Name:** {result.get('name')}\n" + f"- **Beschreibung:** {result.get('description') or '—'}" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 9 – Kapitel erstellen + # ───────────────────────────────────────── + async def create_chapter( + self, + book_id: int, + name: str, + description: str = "", + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Creates a new chapter inside an existing book. + Get book IDs from get_library_structure(). + + :param book_id: The ID of the target book. + :param name: Name of the new chapter. + :param description: Optional short description. + :return: Confirmation with the new chapter's ID. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_CREATE_CHAPTER: + return ( + "❌ Die Funktion 'Kapitel erstellen' ist vom Administrator deaktiviert." + ) + err = self._check_config() + if err: + return err + + try: + api = self._api() + await emitter.status(f"📑 Erstelle Kapitel '{name}' in Buch {book_id}...") + result = api._post( + "chapters", + {"book_id": int(book_id), "name": name, "description": description}, + ) + + await emitter.status("✅ Kapitel erstellt.", done=True) + return ( + f"✅ **Kapitel erfolgreich erstellt!**\n" + f"- **Name:** {name}\n" + f"- **Chapter ID:** `{result.get('id')}`\n" + f"- **Book ID:** `{book_id}`" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg + + # ───────────────────────────────────────── + # TOOL 10 – Kapitel bearbeiten + # ───────────────────────────────────────── + async def update_chapter( + self, + chapter_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + __event_emitter__: Callable[[dict], Any] = None, + ) -> str: + """ + Updates the name and/or description of an existing chapter. + Get chapter IDs from get_library_structure(). + + :param chapter_id: The numeric ID of the chapter to update. + :param name: Optional new name. + :param description: Optional new description. + :return: Confirmation of the update. + """ + emitter = EventEmitter(__event_emitter__) + + if not self.valves.ENABLE_EDIT_CHAPTER: + return "❌ Die Funktion 'Kapitel bearbeiten' ist vom Administrator deaktiviert." + err = self._check_config() + if err: + return err + + if name is None and description is None: + return "❌ Bitte mindestens 'name' oder 'description' angeben." + + try: + api = self._api() + await emitter.status(f"📝 Aktualisiere Kapitel {chapter_id}...") + current = api._get(f"chapters/{int(chapter_id)}") + payload = { + "book_id": current["book_id"], + "name": name if name is not None else current["name"], + "description": ( + description + if description is not None + else current.get("description", "") + ), + } + result = api._put(f"chapters/{chapter_id}", payload) + + await emitter.status("✅ Kapitel aktualisiert.", done=True) + return ( + f"✅ **Kapitel erfolgreich aktualisiert!**\n" + f"- **Chapter ID:** `{chapter_id}`\n" + f"- **Neuer Name:** {result.get('name')}\n" + f"- **Beschreibung:** {result.get('description') or '—'}" + ) + + except requests.HTTPError as e: + body = e.response.text[:500] if e.response is not None else "(kein Body)" + ct = ( + e.response.headers.get("Content-Type", "?") + if e.response is not None + else "?" + ) + log.error( + "[BookStack] HTTP-Fehler %s | Content-Type=%s | Body=%s", + e.response.status_code, + ct, + body, + ) + msg = ( + f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\n" + f"Content-Type: {ct}\n" + f"Antwort: {body}" + ) + await emitter.error(msg) + return msg + except Exception as e: + log.exception("[BookStack] Unerwarteter Fehler: %s", e) + msg = f"❌ Unerwarteter Fehler: {type(e).__name__}: {e}" + await emitter.error(msg) + return msg +