""" 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.4.0 license: MIT requirements: requests """ import re import logging import requests from typing import Any, Callable, Optional from pydantic import BaseModel, Field 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: 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: class Valves(BaseModel): 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)", ) 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", ) 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!)", ) 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): self.valves = self.Valves() def _get_valves(self, __user__: dict) -> "Tools.Valves": """ Workaround für OWUI-Bug: Valves werden nicht korrekt in self.valves injiziert. OWUI übergibt die echten Werte zuverlässig über __user__['valves']. """ user_valves = __user__.get("valves") if __user__ else None if user_valves: try: return self.Valves(**user_valves) if isinstance(user_valves, dict) else user_valves except Exception as e: log.warning("[BookStack] Valve-Parsing aus __user__ fehlgeschlagen: %s", e) return self.valves def _api(self, valves: "Tools.Valves") -> BookStackAPI: return BookStackAPI( valves.BOOKSTACK_URL, valves.API_TOKEN_ID, valves.API_TOKEN_SECRET, ) def _check_config(self, valves: "Tools.Valves") -> Optional[str]: tid = valves.API_TOKEN_ID log.debug( "[BookStack] Config-Check → URL='%s' | TOKEN_ID='%s' | TOKEN_SECRET='%s'", valves.BOOKSTACK_URL, (tid[:4] + "***") if tid else "(leer)", "***" if valves.API_TOKEN_SECRET else "(leer)", ) if not valves.BOOKSTACK_URL: return "❌ Bitte BOOKSTACK_URL in den Valve-Einstellungen eintragen." if not valves.API_TOKEN_ID or not 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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_LIST_STRUCTURE: return "❌ Die Funktion 'Bibliotheksstruktur anzeigen' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) await emitter.status("📚 Lade Shelves...") shelves = api.list_all("shelves", valves.MAX_ITEMS) await emitter.status("📖 Lade Bücher...") books = api.list_all("books", valves.MAX_ITEMS) await emitter.status("📑 Lade Kapitel...") chapters = api.list_all("chapters", valves.MAX_ITEMS) await emitter.status("📄 Lade Seiten...") pages = api.list_all("pages", valves.MAX_ITEMS) await emitter.status("🗂️ Baue Strukturbaum...") 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}\nContent-Type: {ct}\nAntwort: {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", __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_READ_PAGE: return "❌ Die Funktion 'Seite lesen' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_SEARCH: return "❌ Die Funktion 'Suche' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_CREATE_PAGE: return "❌ Die Funktion 'Seite erstellen' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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", __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_EDIT_PAGE: return "❌ Die Funktion 'Seite bearbeiten' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err if title is None and content is None: return "❌ Bitte mindestens 'title' oder 'content' angeben." try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_DELETE_PAGE: return "❌ Die Funktion 'Seite löschen' ist vom Administrator deaktiviert." err = self._check_config(valves) 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(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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 = "", __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_CREATE_BOOK: return "❌ Die Funktion 'Buch erstellen' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_EDIT_BOOK: return "❌ Die Funktion 'Buch bearbeiten' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err if name is None and description is None: return "❌ Bitte mindestens 'name' oder 'description' angeben." try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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 = "", __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_CREATE_CHAPTER: return "❌ Die Funktion 'Kapitel erstellen' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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, __user__: dict = {}, __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. """ valves = self._get_valves(__user__) emitter = EventEmitter(__event_emitter__) if not valves.ENABLE_EDIT_CHAPTER: return "❌ Die Funktion 'Kapitel bearbeiten' ist vom Administrator deaktiviert." err = self._check_config(valves) if err: return err if name is None and description is None: return "❌ Bitte mindestens 'name' oder 'description' angeben." try: api = self._api(valves) 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 "?" msg = f"❌ BookStack API-Fehler: HTTP {e.response.status_code}\nContent-Type: {ct}\nAntwort: {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