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:
@@ -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)
|
||||
Reference in New Issue
Block a user