Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<book-id>`.

## [0.3.6] - 2026-05-28

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/oikb/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions src/oikb/connectors/bookstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@
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", "")
if not self._url or not tid or not ts:
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", []):
Expand All @@ -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}
79 changes: 79 additions & 0 deletions tests/test_bookstack.py
Original file line number Diff line number Diff line change
@@ -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