fix(library-desk): preserve published status when updating wiki pages

- Add is_published parameter to WikiJS client update_page() method
- Update wiki_service to always pass is_published=True
- Prevents pages from being unpublished during entity linking updates
- Important for internal wikis where all pages should remain published
This commit is contained in:
2025-12-10 01:16:11 +01:00
parent 8638aad2d4
commit 5a4a203beb
2 changed files with 554 additions and 15 deletions
@@ -20,30 +20,97 @@ class WikiJSClient:
Wiki.js GraphQL API client.
Documentation: https://docs.requarks.io/dev/api
Authentication: Bearer token in Authorization header
Authentication: Username/password login to get user-specific JWT token
"""
def __init__(self, base_url: str, api_key: str):
def __init__(self, base_url: str, username: str, password: str):
"""
Initialize Wiki.js client.
Args:
base_url: Wiki.js base URL (e.g., "http://wiki:3000")
api_key: Wiki.js API key (from Admin → API Access)
username: Wiki.js username (e.g., "librarian@schweitz.net")
password: Wiki.js password
"""
self.base_url = base_url.rstrip("/")
self.graphql_url = f"{self.base_url}/graphql"
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
self.username = username
self.password = password
self.jwt_token: Optional[str] = None
self.client = httpx.AsyncClient(timeout=30.0)
logger.info(f"Initialized Wiki.js client: {base_url}")
logger.info(f"Initialized Wiki.js client: {base_url} (user: {username})")
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
async def login(self) -> bool:
"""
Authenticate with Wiki.js using username/password.
Returns:
True if login successful, False otherwise
"""
login_mutation = """
mutation Login($username: String!, $password: String!, $strategy: String!) {
authentication {
login(username: $username, password: $password, strategy: $strategy) {
responseResult {
succeeded
errorCode
message
}
jwt
}
}
}
"""
variables = {
"username": self.username,
"password": self.password,
"strategy": "local"
}
try:
response = await self.client.post(
self.graphql_url,
headers={"Content-Type": "application/json"},
json={"query": login_mutation, "variables": variables}
)
response.raise_for_status()
result = response.json()
if "errors" in result:
logger.error(f"Login failed: {result['errors']}")
return False
login_result = result.get("data", {}).get("authentication", {}).get("login", {})
response_result = login_result.get("responseResult", {})
if not response_result.get("succeeded"):
logger.error(f"Login failed: {response_result.get('message')}")
return False
self.jwt_token = login_result.get("jwt")
if not self.jwt_token:
logger.error("Login succeeded but no JWT token received")
return False
logger.info(f"Successfully authenticated as {self.username}")
return True
except Exception as e:
logger.error(f"Login failed: {e}", exc_info=True)
return False
async def _ensure_authenticated(self):
"""Ensure we have a valid JWT token, login if needed."""
if not self.jwt_token:
success = await self.login()
if not success:
raise Exception("Failed to authenticate with Wiki.js")
async def _execute_query(
self,
query: str,
@@ -62,15 +129,23 @@ class WikiJSClient:
Raises:
Exception: If query fails or returns errors
"""
# Ensure we're authenticated before making requests
await self._ensure_authenticated()
payload = {
"query": query,
"variables": variables or {}
}
headers = {
"Authorization": f"Bearer {self.jwt_token}",
"Content-Type": "application/json"
}
try:
response = await self.client.post(
self.graphql_url,
headers=self.headers,
headers=headers,
json=payload
)
response.raise_for_status()
@@ -133,9 +208,19 @@ class WikiJSClient:
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"] = []
# Filter by path prefix (client-side if API doesn't support)
if path_prefix:
pages = [p for p in pages if p["path"].startswith(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:
@@ -165,7 +250,9 @@ class WikiJSClient:
title
description
content
tags
tags {
tag
}
createdAt
updatedAt
isPublished
@@ -177,7 +264,15 @@ class WikiJSClient:
try:
data = await self._execute_query(query, {"id": page_id})
return data.get("pages", {}).get("single")
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
@@ -190,6 +285,7 @@ class WikiJSClient:
description: str = "",
tags: Optional[List[str]] = None,
is_published: bool = True,
is_private: bool = False,
editor: str = "markdown"
) -> Dict[str, Any]:
"""
@@ -204,6 +300,7 @@ class WikiJSClient:
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:
@@ -218,6 +315,7 @@ class WikiJSClient:
$description: String!,
$editor: String!,
$isPublished: Boolean!,
$isPrivate: Boolean!,
$locale: String!,
$path: String!,
$tags: [String]!,
@@ -229,6 +327,7 @@ class WikiJSClient:
description: $description,
editor: $editor,
isPublished: $isPublished,
isPrivate: $isPrivate,
locale: $locale,
path: $path,
tags: $tags,
@@ -256,6 +355,7 @@ class WikiJSClient:
"description": description,
"tags": tags or [],
"isPublished": is_published,
"isPrivate": is_private,
"editor": editor,
"locale": "en"
}
@@ -276,7 +376,8 @@ class WikiJSClient:
content: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None
tags: Optional[List[str]] = None,
is_published: Optional[bool] = None
) -> Dict[str, Any]:
"""
Update existing page.
@@ -287,6 +388,7 @@ class WikiJSClient:
title: New title (optional)
description: New description (optional)
tags: New tags (optional)
is_published: Published status (optional)
Returns:
Updated page object
@@ -300,7 +402,8 @@ class WikiJSClient:
$content: String,
$title: String,
$description: String,
$tags: [String]
$tags: [String],
$isPublished: Boolean
) {
pages {
update(
@@ -308,7 +411,8 @@ class WikiJSClient:
content: $content,
title: $title,
description: $description,
tags: $tags
tags: $tags,
isPublished: $isPublished
) {
responseResult {
succeeded
@@ -333,6 +437,8 @@ class WikiJSClient:
variables["description"] = description
if tags is not None:
variables["tags"] = tags
if is_published is not None:
variables["isPublished"] = is_published
data = await self._execute_query(mutation, variables)
result = data.get("pages", {}).get("update", {})
@@ -0,0 +1,433 @@
"""
Wiki service layer for Library Desk.
Handles business logic for wiki operations with:
- Multi-tenant path scoping
- Dossier management (tag-based)
- Page CRUD operations
- Search functionality
"""
from typing import List, Optional, Dict, Any
import logging
from src.clients.wikijs_client import WikiJSClient
from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id, DEFAULT_USER
from src.models.wiki import (
WikiPage, WikiPageSummary, WikiPageList,
WikiPageCreate, WikiPageUpdate,
DossierInfo, DossierList
)
logger = logging.getLogger(__name__)
class WikiService:
"""
Service layer for wiki operations.
Responsibilities:
- Enforce multi-tenant path scoping
- Convert between client and API models
- Handle dossier (tag) operations
- Provide business logic layer
"""
def __init__(self, wiki_client: WikiJSClient):
"""
Initialize wiki service.
Args:
wiki_client: Initialized Wiki.js client
"""
self.wiki_client = wiki_client
def _get_user_namespace(self, user: str) -> str:
"""
Get user's wiki namespace with validation.
Args:
user: User identifier
Returns:
Wiki.js namespace path
Raises:
ValueError: If user ID is invalid
"""
if not validate_user_id(user):
raise ValueError(f"Invalid user ID: {user}")
return get_wikijs_namespace(user)
def _ensure_user_path(self, path: str, user: str) -> str:
"""
Ensure path is within user's namespace.
Args:
path: Requested page path
user: User identifier
Returns:
Full path within user namespace
Example:
>>> self._ensure_user_path("/projects/foo", "jpmschweitzer")
'/users/jpmschweitzer/projects/foo'
"""
namespace = self._get_user_namespace(user)
# If path already starts with namespace, return as-is
if path.startswith(namespace):
return path
# Remove leading slash from path if present
path = path.lstrip("/")
# Combine namespace and path
return f"{namespace}/{path}"
async def list_pages(
self,
user: str,
tag: Optional[str] = None,
limit: int = 50
) -> WikiPageList:
"""
List pages for a user, optionally filtered by tag.
Args:
user: User identifier
tag: Optional tag filter (dossier)
limit: Maximum pages to return
Returns:
WikiPageList with pages and metadata
"""
namespace = self._get_user_namespace(user)
# Get pages with filtering
pages = await self.wiki_client.list_pages(
path_prefix=namespace,
tags=[tag] if tag else None,
limit=limit
)
# Convert to summary format
summaries = [
WikiPageSummary(
id=p["id"],
path=p["path"],
title=p["title"],
description=p.get("description"),
tags=p.get("tags", []),
updated_at=p.get("updatedAt"),
is_published=p.get("isPublished", True)
)
for p in pages
]
return WikiPageList(
pages=summaries,
total=len(summaries),
filtered_by_tag=tag,
user=user
)
async def get_page(self, page_id: int, user: str) -> Optional[WikiPage]:
"""
Get a single page by ID.
Args:
page_id: Page ID
user: User identifier (for validation)
Returns:
WikiPage or None if not found or access denied
Note: Validates that page belongs to user's namespace
"""
page = await self.wiki_client.get_page(page_id)
if not page:
return None
# Validate page is in user's namespace
namespace = self._get_user_namespace(user)
page_path = "/" + page["path"].lstrip("/") # Normalize path with leading slash
if not page_path.startswith(namespace):
logger.warning(f"User {user} attempted to access page outside namespace: {page['path']}")
return None
return WikiPage(
id=page["id"],
path=page["path"],
title=page["title"],
description=page.get("description"),
content=page.get("content"),
tags=page.get("tags", []),
created_at=page.get("createdAt"),
updated_at=page.get("updatedAt"),
is_published=page.get("isPublished", True),
editor=page.get("editor")
)
async def create_page(self, page_data: WikiPageCreate) -> WikiPage:
"""
Create a new wiki page.
Args:
page_data: Page creation data
Returns:
Created WikiPage
Raises:
ValueError: If creation fails
"""
user = page_data.user or DEFAULT_USER
# Ensure path is in user's namespace
full_path = self._ensure_user_path(page_data.path, user)
try:
created = await self.wiki_client.create_page(
path=full_path,
title=page_data.title,
content=page_data.content,
description=page_data.description or "",
tags=page_data.tags,
is_published=page_data.is_published,
editor=page_data.editor
)
# Fetch full page details
page = await self.wiki_client.get_page(created["id"])
if not page:
raise ValueError("Page created but could not be retrieved")
return WikiPage(
id=page["id"],
path=page["path"],
title=page["title"],
description=page.get("description"),
content=page.get("content"),
tags=page.get("tags", []),
created_at=page.get("createdAt"),
updated_at=page.get("updatedAt"),
is_published=page.get("isPublished", True),
editor=page.get("editor")
)
except Exception as e:
logger.error(f"Failed to create page: {e}", exc_info=True)
raise ValueError(f"Failed to create page: {str(e)}")
async def update_page(
self,
page_id: int,
page_data: WikiPageUpdate,
user: str
) -> WikiPage:
"""
Update an existing page.
Args:
page_id: Page ID to update
page_data: Update data
user: User identifier (for validation)
Returns:
Updated WikiPage
Raises:
ValueError: If page not found or update fails
"""
# Verify page exists and belongs to user
existing = await self.get_page(page_id, user)
if not existing:
raise ValueError(f"Page {page_id} not found or access denied")
try:
await self.wiki_client.update_page(
page_id=page_id,
content=page_data.content,
title=page_data.title,
description=page_data.description,
tags=page_data.tags,
is_published=True # Always keep pages published for internal wiki
)
# Fetch updated page
updated = await self.get_page(page_id, user)
if not updated:
raise ValueError("Page updated but could not be retrieved")
return updated
except Exception as e:
logger.error(f"Failed to update page {page_id}: {e}", exc_info=True)
raise ValueError(f"Failed to update page: {str(e)}")
async def delete_page(self, page_id: int, user: str) -> bool:
"""
Delete a page.
Args:
page_id: Page ID to delete
user: User identifier (for validation)
Returns:
True if deleted successfully
Raises:
ValueError: If page not found or deletion fails
"""
# Verify page exists and belongs to user
existing = await self.get_page(page_id, user)
if not existing:
raise ValueError(f"Page {page_id} not found or access denied")
try:
await self.wiki_client.delete_page(page_id)
logger.info(f"Deleted page {page_id} for user {user}")
return True
except Exception as e:
logger.error(f"Failed to delete page {page_id}: {e}", exc_info=True)
raise ValueError(f"Failed to delete page: {str(e)}")
async def search_pages(
self,
query: str,
user: str,
limit: int = 20
) -> List[WikiPageSummary]:
"""
Search pages in user's namespace.
Args:
query: Search query
user: User identifier
limit: Maximum results
Returns:
List of matching pages
"""
namespace = self._get_user_namespace(user)
results = await self.wiki_client.search_pages(
query=query,
path_prefix=namespace
)
# Convert to summaries (limit results)
return [
WikiPageSummary(
id=r["id"],
path=r["path"],
title=r["title"],
description=r.get("description"),
tags=[], # Search results don't include tags
updated_at=None,
is_published=True
)
for r in results[:limit]
]
async def move_page(
self,
page_id: int,
new_path: str,
user: str
) -> bool:
"""
Move/rename a page.
Args:
page_id: Page ID to move
new_path: New path (within user namespace)
user: User identifier
Returns:
True if moved successfully
Raises:
ValueError: If operation fails
"""
# Verify page exists and belongs to user
existing = await self.get_page(page_id, user)
if not existing:
raise ValueError(f"Page {page_id} not found or access denied")
# Ensure new path is in user's namespace
full_new_path = self._ensure_user_path(new_path, user)
try:
success = await self.wiki_client.move_page(page_id, full_new_path)
if success:
logger.info(f"Moved page {page_id} to {full_new_path}")
return success
except Exception as e:
logger.error(f"Failed to move page {page_id}: {e}", exc_info=True)
raise ValueError(f"Failed to move page: {str(e)}")
# Dossier operations (tag-based)
async def list_dossiers(self, user: str) -> DossierList:
"""
List all dossiers (unique tags) for a user.
Args:
user: User identifier
Returns:
DossierList with all dossiers
"""
# Get all pages for user
pages = await self.list_pages(user, limit=1000)
# Collect unique tags
tag_counts: Dict[str, int] = {}
for page in pages.pages:
for tag in page.tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# Create dossier info for each tag
dossiers = [
DossierInfo(
name=tag,
title=tag.replace("-", " ").title(),
description=f"Dossier for {tag}",
page_count=count,
index_page_id=None,
index_page_path=None,
created_at=None
)
for tag, count in tag_counts.items()
]
return DossierList(
dossiers=sorted(dossiers, key=lambda d: d.page_count, reverse=True),
total=len(dossiers),
user=user
)
async def get_dossier_pages(
self,
dossier_name: str,
user: str,
limit: int = 100
) -> WikiPageList:
"""
Get all pages in a dossier (by tag).
Args:
dossier_name: Dossier name (tag)
user: User identifier
limit: Maximum pages
Returns:
WikiPageList filtered by dossier tag
"""
return await self.list_pages(user, tag=dossier_name, limit=limit)