""" 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 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 Returns: List of page objects """ 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"] = [] # Filter by path prefix (client-side if API doesn't support) 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_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 batches until all are retrieved. Args: path_prefix: Filter by path prefix (e.g., "users/jpmschweitzer") tags: Filter by tags batch_size: Number of pages per batch (max 100) Returns: Complete list of page objects """ all_pages = [] offset = 0 while True: # Wiki.js list doesn't support offset, but limit is enough # since we filter client-side by path_prefix # Just fetch a large batch pages = await self.list_pages( path_prefix=path_prefix, tags=tags, limit=1000 # Fetch up to 1000 at once ) if not pages: break all_pages = pages break # Wiki.js list doesn't paginate, so one call is enough 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 } } } } """ variables = {"id": page_id} 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 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", {}) 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 if path_prefix: results = [r for r in results if r["path"].startswith(path_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