diff --git a/services/library-desk/src/clients/wikijs_client.py b/services/library-desk/src/clients/wikijs_client.py index 86597b8..76ea589 100644 --- a/services/library-desk/src/clients/wikijs_client.py +++ b/services/library-desk/src/clients/wikijs_client.py @@ -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", {}) diff --git a/services/library-desk/src/services/wiki_service.py b/services/library-desk/src/services/wiki_service.py new file mode 100644 index 0000000..4a8c5ca --- /dev/null +++ b/services/library-desk/src/services/wiki_service.py @@ -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)