diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2ca60..809001a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- **BookStack**: support syncing pages from a specific book via `bookstack:`. + ## [0.3.6] - 2026-05-28 ### Added diff --git a/README.md b/README.md index 8e11a8f..26226fe 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ services: ```bash oikb sync github:owner/repo --kb-id your-kb-id oikb sync confluence:ENG --kb-id your-kb-id +oikb sync bookstack:12 --kb-id your-kb-id oikb sync s3://bucket/prefix --kb-id your-kb-id oikb sync nextcloud:/Documents --kb-id your-kb-id oikb sync servicenow:incident --kb-id your-kb-id diff --git a/docs/guide.md b/docs/guide.md index 905d74a..cc2cde5 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -20,6 +20,7 @@ A complete guide to syncing content into Open WebUI Knowledge Bases. - [GitHub](#github) - [GitLab / Bitbucket](#gitlab--bitbucket) - [Confluence](#confluence) + - [BookStack](#bookstack) - [Cloud Storage (S3 / GCS / Azure)](#cloud-storage-s3--gcs--azure) - [SharePoint](#sharepoint) - [Nextcloud](#nextcloud) @@ -250,6 +251,16 @@ oikb sync confluence:SPACE_KEY --kb-id your-kb-id Requires `CONFLUENCE_URL`, `CONFLUENCE_USERNAME`, and `CONFLUENCE_API_TOKEN`. +### BookStack + +```bash +oikb sync bookstack: --kb-id your-kb-id # all pages +oikb sync bookstack:12 --kb-id your-kb-id # one book ID +``` + +Requires `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID`, and `BOOKSTACK_TOKEN_SECRET`. +Use one Knowledge Base per book if you want to combine multiple BookStack books in Open WebUI. + ### Cloud Storage (S3 / GCS / Azure) ```bash diff --git a/src/oikb/cli.py b/src/oikb/cli.py index 39250e2..f8e2f7c 100644 --- a/src/oikb/cli.py +++ b/src/oikb/cli.py @@ -161,8 +161,9 @@ def _resolve_connector(source: str, branch: str | None = None, path: str | None return SalesforceConnector() if source.startswith("bookstack:"): - from oikb.connectors.bookstack import BookStackConnector - return BookStackConnector() + from oikb.connectors.bookstack import BookStackConnector, parse_bookstack_source + parsed = parse_bookstack_source(source) + return BookStackConnector(book_id=parsed.get("book_id")) if source.startswith("discourse:"): from oikb.connectors.discourse import DiscourseConnector, parse_discourse_source diff --git a/src/oikb/connectors/bookstack.py b/src/oikb/connectors/bookstack.py index 8d86c30..f8c8fd0 100644 --- a/src/oikb/connectors/bookstack.py +++ b/src/oikb/connectors/bookstack.py @@ -17,7 +17,13 @@ class BookStackConnector(BaseConnector): """Sync pages from BookStack.""" - def __init__(self, base_url: str | None = None, token_id: str | None = None, token_secret: str | None = None): + def __init__( + self, + base_url: str | None = None, + token_id: str | None = None, + token_secret: str | None = None, + book_id: str | None = None, + ): self._url = (base_url or os.environ.get("BOOKSTACK_URL", "")).rstrip("/") tid = token_id or os.environ.get("BOOKSTACK_TOKEN_ID", "") ts = token_secret or os.environ.get("BOOKSTACK_TOKEN_SECRET", "") @@ -25,12 +31,16 @@ def __init__(self, base_url: str | None = None, token_id: str | None = None, tok raise ValueError("BookStack credentials required. Set BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, BOOKSTACK_TOKEN_SECRET.") self._http = httpx.Client(base_url=self._url, headers={"Authorization": f"Token {tid}:{ts}"}, timeout=30.0) self._cache: dict[str, str] = {} + self.book_id = book_id def build_manifest(self) -> list[ManifestEntry]: entries: list[ManifestEntry] = [] offset = 0 while True: - resp = self._http.get("/api/pages", params={"count": 100, "offset": offset}) + params: dict[str, int | str] = {"count": 100, "offset": offset} + if self.book_id: + params["filter[book_id]"] = self.book_id + resp = self._http.get("/api/pages", params=params) resp.raise_for_status() data = resp.json() for page in data.get("data", []): @@ -57,4 +67,10 @@ def close(self) -> None: def parse_bookstack_source(source: str) -> dict[str, str | None]: - return {} + book_id = source.removeprefix("bookstack:") + if not book_id: + return {"book_id": None} + + if not book_id.isdecimal(): + raise ValueError("Invalid BookStack source. Expected a numeric book ID, e.g. bookstack:12") + return {"book_id": book_id} diff --git a/tests/test_bookstack.py b/tests/test_bookstack.py new file mode 100644 index 0000000..d05191a --- /dev/null +++ b/tests/test_bookstack.py @@ -0,0 +1,79 @@ +import pytest +import respx +from httpx import Response + +from oikb.connectors.bookstack import BookStackConnector, parse_bookstack_source + + +def test_parse_bookstack_source_without_book_id(): + assert parse_bookstack_source("bookstack:") == {"book_id": None} + + +def test_parse_bookstack_source_with_book_id(): + assert parse_bookstack_source("bookstack:12") == {"book_id": "12"} + + +@pytest.mark.parametrize("source", ["bookstack:abc", "bookstack:12,34", "bookstack:12/34"]) +def test_parse_bookstack_source_rejects_invalid_book_id(source): + with pytest.raises(ValueError, match="numeric book ID"): + parse_bookstack_source(source) + + +@respx.mock +def test_build_manifest_filters_by_book_id(): + route = respx.get("https://bookstack.example/api/pages").mock( + return_value=Response( + 200, + json={ + "data": [ + { + "id": 99, + "book_id": 12, + "name": "Filtered Page", + "updated_at": "2026-07-14T12:00:00Z", + } + ], + "total": 1, + }, + ) + ) + + connector = BookStackConnector( + base_url="https://bookstack.example", + token_id="id", + token_secret="secret", + book_id="12", + ) + try: + manifest = connector.build_manifest() + finally: + connector.close() + + params = route.calls[0].request.url.params + assert params["count"] == "100" + assert params["offset"] == "0" + assert params["filter[book_id]"] == "12" + assert len(manifest) == 1 + assert manifest[0].filename == "99_Filtered Page.txt" + + +@respx.mock +def test_build_manifest_without_book_id_keeps_unfiltered_request(): + route = respx.get("https://bookstack.example/api/pages").mock( + return_value=Response(200, json={"data": [], "total": 0}) + ) + + connector = BookStackConnector( + base_url="https://bookstack.example", + token_id="id", + token_secret="secret", + ) + try: + assert connector.build_manifest() == [] + finally: + connector.close() + + params = route.calls[0].request.url.params + assert params["count"] == "100" + assert params["offset"] == "0" + assert "filter[book_id]" not in params