""" Trilium notes executor for searching and creating notes via ETAPI. This module provides integration with Trilium notes through the ETAPI HTTP API with improved resilience: timeout configuration, retry logic, and connection pooling. """ import os import asyncio import aiohttp from typing import Dict, Optional from ..utils.resilience import async_retry TRILIUM_URL = os.getenv("TRILIUM_URL", "http://localhost:8080") TRILIUM_TOKEN = os.getenv("TRILIUM_ETAPI_TOKEN", "") # Module-level session for connection pooling _session: Optional[aiohttp.ClientSession] = None def get_session() -> aiohttp.ClientSession: """Get or create shared aiohttp session for connection pooling.""" global _session if _session is None or _session.closed: timeout = aiohttp.ClientTimeout( total=float(os.getenv("TRILIUM_TIMEOUT", "30.0")), connect=float(os.getenv("TRILIUM_CONNECT_TIMEOUT", "10.0")) ) _session = aiohttp.ClientSession(timeout=timeout) return _session @async_retry( max_attempts=3, exceptions=(aiohttp.ClientError, asyncio.TimeoutError) ) async def search_notes(args: Dict) -> Dict: """Search Trilium notes via ETAPI with retry logic. Args: args: Dictionary containing: - query (str): Search query - limit (int, optional): Maximum notes to return (default: 5, max: 20) Returns: dict: Search results containing: - notes (list): List of notes with noteId, title, content, type - count (int): Number of notes returned OR - error (str): Error message if search failed """ query = args.get("query") limit = args.get("limit", 5) # Validation if not query: return {"error": "No query provided"} if not TRILIUM_TOKEN: return { "error": "TRILIUM_ETAPI_TOKEN not configured in environment", "hint": "Set TRILIUM_ETAPI_TOKEN in .env file" } # Cap limit limit = min(max(limit, 1), 20) try: session = get_session() async with session.get( f"{TRILIUM_URL}/etapi/notes", params={"search": query, "limit": limit}, headers={"Authorization": TRILIUM_TOKEN} ) as resp: if resp.status == 200: data = await resp.json() # ETAPI returns {"results": [...]} format results = data.get("results", []) return { "notes": results, "count": len(results) } elif resp.status == 401: return { "error": "Authentication failed. Check TRILIUM_ETAPI_TOKEN", "status": 401 } elif resp.status == 404: return { "error": "Trilium API endpoint not found. Check TRILIUM_URL", "status": 404, "url": TRILIUM_URL } else: error_text = await resp.text() return { "error": f"HTTP {resp.status}: {error_text}", "status": resp.status } except aiohttp.ClientConnectorError as e: return { "error": f"Cannot connect to Trilium at {TRILIUM_URL}", "hint": "Check if Trilium is running and URL is correct", "details": str(e) } except asyncio.TimeoutError: timeout = os.getenv("TRILIUM_TIMEOUT", "30.0") return { "error": f"Trilium request timeout after {timeout}s", "hint": "Trilium may be slow or unresponsive" } except Exception as e: return { "error": f"Search failed: {str(e)}", "type": type(e).__name__ } @async_retry( max_attempts=3, exceptions=(aiohttp.ClientError, asyncio.TimeoutError) ) async def create_note(args: Dict) -> Dict: """Create a note in Trilium via ETAPI with retry logic. Args: args: Dictionary containing: - title (str): Note title - content (str): Note content in markdown or HTML - parent_note_id (str, optional): Parent note ID to nest under Returns: dict: Creation result containing: - noteId (str): ID of created note - title (str): Title of created note - success (bool): True if created successfully OR - error (str): Error message if creation failed """ title = args.get("title") content = args.get("content") parent_note_id = args.get("parent_note_id", "root") # Default to root if not specified # Validation if not title: return {"error": "No title provided"} if not content: return {"error": "No content provided"} if not TRILIUM_TOKEN: return { "error": "TRILIUM_ETAPI_TOKEN not configured in environment", "hint": "Set TRILIUM_ETAPI_TOKEN in .env file" } # Prepare payload payload = { "parentNoteId": parent_note_id, # Always include parentNoteId "title": title, "content": content, "type": "text", "mime": "text/html" } try: session = get_session() async with session.post( f"{TRILIUM_URL}/etapi/create-note", json=payload, headers={"Authorization": TRILIUM_TOKEN} ) as resp: if resp.status in [200, 201]: data = await resp.json() return { "noteId": data.get("noteId"), "title": title, "success": True } elif resp.status == 401: return { "error": "Authentication failed. Check TRILIUM_ETAPI_TOKEN", "status": 401 } elif resp.status == 404: return { "error": "Trilium API endpoint not found. Check TRILIUM_URL", "status": 404, "url": TRILIUM_URL } else: error_text = await resp.text() return { "error": f"HTTP {resp.status}: {error_text}", "status": resp.status } except aiohttp.ClientConnectorError as e: return { "error": f"Cannot connect to Trilium at {TRILIUM_URL}", "hint": "Check if Trilium is running and URL is correct", "details": str(e) } except asyncio.TimeoutError: timeout = os.getenv("TRILIUM_TIMEOUT", "30.0") return { "error": f"Trilium request timeout after {timeout}s", "hint": "Trilium may be slow or unresponsive" } except Exception as e: return { "error": f"Note creation failed: {str(e)}", "type": type(e).__name__ }