Files
library-desk/src/clients/wikijs_client.py
T
jpmschweitzerandClaude Fable 5 0e57be75be fix: normalize path prefix in wiki search so tenant filtering matches
get_wikijs_namespace() returns '/users/{user}' with a leading slash while
Wiki.js search results carry paths without one, so the prefix filter in
search_pages rejected every result - wiki search returned empty for every
tenant. Found by cross-repo integration verification; the librarian now
gets real search results. Compare slash-normalized on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:33:00 +02:00

691 lines
21 KiB
Python

"""
Wiki.js GraphQL client for Library Desk.
Provides async Wiki.js operations via GraphQL API:
- Page CRUD (create, read, update, delete)
- Search and listing
- Tag management
- Multi-tenancy via path namespaces
"""
import httpx
from typing import Optional, List, Dict, Any
import logging
logger = logging.getLogger(__name__)
class WikiJSClient:
"""
Wiki.js GraphQL API client.
Documentation: https://docs.requarks.io/dev/api
Authentication: API token (JWT) generated from Wiki.js admin panel
"""
def __init__(self, base_url: str, api_token: str):
"""
Initialize Wiki.js client.
Args:
base_url: Wiki.js base URL (e.g., "http://wiki:3000")
api_token: Wiki.js API token (JWT from admin panel)
"""
self.base_url = base_url.rstrip("/")
self.graphql_url = f"{self.base_url}/graphql"
self.api_token = api_token
self.client = httpx.AsyncClient(timeout=30.0)
auth_mode = "with API token" if api_token else "without auth (open API)"
logger.info(f"Initialized Wiki.js client: {base_url} ({auth_mode})")
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
def _get_headers(self) -> Dict[str, str]:
"""Get request headers, optionally including auth token."""
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
async def _execute_query(
self,
query: str,
variables: Optional[Dict] = None
) -> Dict[str, Any]:
"""
Execute GraphQL query.
Args:
query: GraphQL query string
variables: Query variables
Returns:
Query result data
Raises:
Exception: If query fails or returns errors
"""
payload = {
"query": query,
"variables": variables or {}
}
headers = self._get_headers()
try:
response = await self.client.post(
self.graphql_url,
headers=headers,
json=payload
)
response.raise_for_status()
result = response.json()
if "errors" in result:
logger.error(f"GraphQL errors: {result['errors']}")
raise Exception(f"GraphQL errors: {result['errors']}")
return result.get("data", {})
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
raise
except Exception as e:
logger.error(f"GraphQL query failed: {e}", exc_info=True)
raise
async def _fetch_pages(self, limit: int) -> List[Dict[str, Any]]:
"""
Fetch a raw page listing from the GraphQL API (no client-side filtering).
Args:
limit: Maximum pages to request from the API
Returns:
List of page objects with tags normalized to a list
"""
query = """
query ListPages($limit: Int, $orderBy: PageOrderBy) {
pages {
list(limit: $limit, orderBy: $orderBy) {
id
path
title
description
tags
createdAt
updatedAt
isPublished
}
}
}
"""
variables = {
"limit": limit,
"orderBy": "TITLE"
}
data = await self._execute_query(query, variables)
pages = data.get("pages", {}).get("list", [])
# Ensure tags is always a list
for page in pages:
if "tags" not in page or page["tags"] is None:
page["tags"] = []
return pages
async def _fetch_all_pages(self, initial_limit: int = 100) -> List[Dict[str, Any]]:
"""
Fetch ALL pages from the GraphQL API.
The Wiki.js 2.x `pages.list` query only supports a `limit` argument
(no offset - verified via GraphQL introspection). Crucially, the
limit is applied BEFORE Wiki.js's own visibility filtering, so a
response with fewer pages than requested does NOT mean the listing
is complete (observed live: limit=100 -> 43 pages, limit=500 ->
140 pages). Exhaustive listing therefore grows the limit until the
returned page count stops increasing.
Args:
initial_limit: Page count for the first request
Returns:
Complete list of page objects
"""
max_limit = 100_000 # Safety cap against pathological growth
limit = max(initial_limit, 1)
previous_count: Optional[int] = None
while True:
pages = await self._fetch_pages(limit)
# Complete when a grown limit yields no new pages (fixed point)
if previous_count is not None and len(pages) == previous_count:
return pages
if limit >= max_limit:
return pages
previous_count = len(pages)
limit = min(limit * 2, max_limit)
@staticmethod
def _filter_pages(
pages: List[Dict[str, Any]],
path_prefix: str = "",
tags: Optional[List[str]] = None
) -> List[Dict[str, Any]]:
"""Apply client-side path-prefix and tag filters to a page listing."""
if path_prefix:
# Normalize paths to have leading slash for consistent comparison
normalized_prefix = "/" + path_prefix.lstrip("/")
pages = [
p for p in pages
if ("/" + p["path"].lstrip("/")).startswith(normalized_prefix)
]
# Filter by tags (dossiers)
if tags:
pages = [
p for p in pages
if any(tag in (p.get("tags") or []) for tag in tags)
]
return pages
async def list_pages(
self,
path_prefix: str = "",
tags: Optional[List[str]] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
List pages with optional filtering.
Multi-tenancy: Use path_prefix to filter by user namespace.
Args:
path_prefix: Filter by path prefix (e.g., "/users/jpmschweitzer")
tags: Filter by tags (e.g., ["projects"])
limit: Maximum results (applied AFTER filtering)
Returns:
List of page objects
"""
if path_prefix or tags:
# The API limit applies before our client-side filters, so a
# small limit would drop matching pages that sort late. Fetch
# everything, filter, then apply the limit.
pages = self._filter_pages(
await self._fetch_all_pages(),
path_prefix=path_prefix,
tags=tags
)
return pages[:limit]
return await self._fetch_pages(limit)
async def list_all_pages(
self,
path_prefix: str = "",
tags: Optional[List[str]] = None,
batch_size: int = 100
) -> List[Dict[str, Any]]:
"""
List ALL pages with pagination support.
Fetches pages in growing batches until all are retrieved (Wiki.js
`pages.list` has no offset argument), then applies filters.
Args:
path_prefix: Filter by path prefix (e.g., "users/jpmschweitzer")
tags: Filter by tags
batch_size: Page count for the first request
Returns:
Complete list of page objects
"""
all_pages = self._filter_pages(
await self._fetch_all_pages(initial_limit=batch_size),
path_prefix=path_prefix,
tags=tags
)
logger.info(f"list_all_pages: found {len(all_pages)} pages (prefix: {path_prefix or 'all'})")
return all_pages
async def get_taxonomy_structure(
self,
user_namespace: str,
limit: int = 500
) -> Dict[str, List[str]]:
"""
Get the existing taxonomy structure for a user namespace.
Returns a dict mapping top-level categories to their subcategories.
This helps the LLM choose existing paths rather than creating new ones.
Args:
user_namespace: User namespace (e.g., "users/jpmschweitzer")
limit: Maximum pages to fetch
Returns:
Dict like {"reference": ["political-entities", "tech"], "places": ["the-netherlands"]}
"""
from collections import defaultdict
pages = await self.list_pages(path_prefix=user_namespace, limit=limit)
# Extract structure: category -> set of subcategories
structure: Dict[str, set] = defaultdict(set)
prefix = user_namespace.strip("/") + "/"
for page in pages:
path = page.get("path", "")
if not path.startswith(prefix):
continue
# Get relative path within user namespace
rel_path = path[len(prefix):]
parts = rel_path.split("/")
if len(parts) >= 1:
category = parts[0]
if len(parts) >= 2:
# Has subcategory (e.g., reference/political-entities/nato)
structure[category].add(parts[1])
else:
# Just ensure category exists in structure
structure[category] # Access to ensure key exists
# Convert sets to sorted lists
return {cat: sorted(list(subs)) for cat, subs in sorted(structure.items())}
async def get_page(self, page_id: int) -> Optional[Dict[str, Any]]:
"""
Get single page by ID.
Args:
page_id: Page ID
Returns:
Page object or None if not found
"""
query = """
query GetPage($id: Int!) {
pages {
single(id: $id) {
id
path
title
description
content
tags {
tag
}
createdAt
updatedAt
isPublished
editor
}
}
}
"""
try:
# Ensure page_id is int (GraphQL requires Int, not String)
data = await self._execute_query(query, {"id": int(page_id)})
page = data.get("pages", {}).get("single")
# Extract tag strings from tag objects
if page and "tags" in page and page["tags"]:
page["tags"] = [t["tag"] for t in page["tags"]]
elif page:
page["tags"] = []
return page
except Exception as e:
logger.error(f"Failed to get page {page_id}: {e}")
return None
async def create_page(
self,
path: str,
title: str,
content: str,
description: str = "",
tags: Optional[List[str]] = None,
is_published: bool = True,
is_private: bool = False,
editor: str = "markdown"
) -> Dict[str, Any]:
"""
Create new page.
Multi-tenancy: Ensure path starts with user namespace.
Args:
path: Page path (e.g., "/users/jpmschweitzer/projects/library-desk")
title: Page title
content: Page content (markdown)
description: Short description
tags: List of tags (for dossier organization)
is_published: Whether page is published
is_private: Whether page is private
editor: Editor type (markdown, wysiwyg, etc.)
Returns:
Created page object
Raises:
Exception: If creation fails
"""
mutation = """
mutation CreatePage(
$content: String!,
$description: String!,
$editor: String!,
$isPublished: Boolean!,
$isPrivate: Boolean!,
$locale: String!,
$path: String!,
$tags: [String]!,
$title: String!
) {
pages {
create(
content: $content,
description: $description,
editor: $editor,
isPublished: $isPublished,
isPrivate: $isPrivate,
locale: $locale,
path: $path,
tags: $tags,
title: $title
) {
responseResult {
succeeded
errorCode
message
}
page {
id
path
title
}
}
}
}
"""
variables = {
"path": path,
"title": title,
"content": content,
"description": description,
"tags": tags or [],
"isPublished": is_published,
"isPrivate": is_private,
"editor": editor,
"locale": "en"
}
data = await self._execute_query(mutation, variables)
result = data.get("pages", {}).get("create", {})
if not result.get("responseResult", {}).get("succeeded"):
error = result.get("responseResult", {})
raise Exception(f"Failed to create page: {error}")
logger.info(f"Created page: {path}")
return result.get("page")
async def update_page(
self,
page_id: int,
content: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
is_published: Optional[bool] = None
) -> Dict[str, Any]:
"""
Update existing page.
Args:
page_id: Page ID
content: New content (optional)
title: New title (optional)
description: New description (optional)
tags: New tags (optional)
is_published: Published status (optional)
Returns:
Updated page object
Raises:
Exception: If update fails
"""
mutation = """
mutation UpdatePage(
$id: Int!,
$content: String,
$title: String,
$description: String,
$tags: [String],
$isPublished: Boolean
) {
pages {
update(
id: $id,
content: $content,
title: $title,
description: $description,
tags: $tags,
isPublished: $isPublished
) {
responseResult {
succeeded
errorCode
message
}
page {
id
updatedAt
}
}
}
}
"""
# Wiki.js 2.x requires `tags` on the update mutation (the server
# unconditionally maps over it; omitting it fails with "Cannot read
# properties of undefined (reading 'map')"). Preserve the page's
# current tags when the caller does not supply any.
if tags is None:
current = await self.get_page(page_id)
tags = (current or {}).get("tags") or []
variables = {"id": page_id, "tags": tags}
if content is not None:
variables["content"] = content
if title is not None:
variables["title"] = title
if description is not None:
variables["description"] = description
if is_published is not None:
variables["isPublished"] = is_published
data = await self._execute_query(mutation, variables)
result = data.get("pages", {}).get("update", {})
if not result.get("responseResult", {}).get("succeeded"):
error = result.get("responseResult", {})
raise Exception(f"Failed to update page: {error}")
logger.info(f"Updated page: {page_id}")
return result.get("page")
async def delete_page(self, page_id: int):
"""
Delete page.
Args:
page_id: Page ID
Raises:
Exception: If deletion fails
"""
mutation = """
mutation DeletePage($id: Int!) {
pages {
delete(id: $id) {
responseResult {
succeeded
errorCode
message
}
}
}
}
"""
data = await self._execute_query(mutation, {"id": page_id})
result = data.get("pages", {}).get("delete", {})
if not result.get("responseResult", {}).get("succeeded"):
error = result.get("responseResult", {})
raise Exception(f"Failed to delete page: {error}")
logger.info(f"Deleted page: {page_id}")
async def search_pages(
self,
query: str,
path_prefix: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Search pages by content.
Args:
query: Search query
path_prefix: Optional path prefix filter
Returns:
List of matching pages
"""
gql_query = """
query SearchPages($query: String!) {
pages {
search(query: $query) {
results {
id
path
title
description
}
}
}
}
"""
data = await self._execute_query(gql_query, {"query": query})
results = data.get("pages", {}).get("search", {}).get("results", [])
# Filter by path prefix if provided. Wiki.js returns paths WITHOUT a
# leading slash while get_wikijs_namespace() produces one WITH it, so
# compare slash-normalized (the mismatch made this filter reject every
# result, returning an empty search for every tenant).
if path_prefix:
prefix = path_prefix.lstrip("/")
results = [r for r in results if r["path"].lstrip("/").startswith(prefix)]
return results
async def get_page_tree(self, path: str = "/") -> List[Dict[str, Any]]:
"""
Get page tree structure.
Args:
path: Root path
Returns:
Tree structure of pages
"""
query = """
query GetPageTree($parent: Int, $mode: String!) {
pages {
tree(parent: $parent, mode: $mode) {
id
path
title
isFolder
pageId
}
}
}
"""
try:
data = await self._execute_query(
query,
{"parent": 0, "mode": "all"}
)
return data.get("pages", {}).get("tree", [])
except Exception as e:
logger.error(f"Failed to get page tree: {e}")
return []
async def move_page(
self,
page_id: int,
new_path: str,
locale: str = "en"
) -> bool:
"""
Move/rename page.
Args:
page_id: Page ID
new_path: New page path
locale: Page locale
Returns:
True if successful
"""
mutation = """
mutation MovePage($id: Int!, $destinationPath: String!, $destinationLocale: String!) {
pages {
move(id: $id, destinationPath: $destinationPath, destinationLocale: $destinationLocale) {
responseResult {
succeeded
errorCode
message
}
}
}
}
"""
try:
data = await self._execute_query(
mutation,
{
"id": page_id,
"destinationPath": new_path,
"destinationLocale": locale
}
)
result = data.get("pages", {}).get("move", {})
success = result.get("responseResult", {}).get("succeeded", False)
if success:
logger.info(f"Moved page {page_id} to {new_path}")
return success
except Exception as e:
logger.error(f"Failed to move page: {e}")
return False