934 lines
39 KiB
Python
934 lines
39 KiB
Python
"""
|
||
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 |