# ETAPI Complete Guide ## Table of Contents 1. [Introduction](#introduction) 2. [Authentication Setup](#authentication-setup) 3. [API Endpoints](#api-endpoints) 4. [Common Use Cases](#common-use-cases) 5. [Client Library Examples](#client-library-examples) 6. [Rate Limiting and Best Practices](#rate-limiting-and-best-practices) 7. [Migration from Internal API](#migration-from-internal-api) 8. [Error Handling](#error-handling) 9. [Performance Considerations](#performance-considerations) ## Introduction ETAPI (External Trilium API) is the recommended REST API for external integrations with Trilium Notes. It provides a secure, stable interface for programmatic access to notes, attributes, branches, and attachments. ### Key Features - RESTful design with predictable endpoints - Token-based authentication - Comprehensive CRUD operations - Search functionality - Import/export capabilities - Calendar and special note access ### Base URL ``` http://localhost:8080/etapi ``` ## Authentication Setup ### Method 1: Token Authentication #### Step 1: Generate ETAPI Token 1. Open Trilium Notes 2. Navigate to **Options** → **ETAPI** 3. Click "Create new ETAPI token" 4. Copy the generated token #### Step 2: Use Token in Requests **HTTP Header:** ``` Authorization: ``` **cURL Example:** ```bash curl -X GET http://localhost:8080/etapi/notes/root \ -H "Authorization: myEtapiToken123" ``` **Python Example:** ```python import requests headers = { 'Authorization': 'myEtapiToken123' } response = requests.get('http://localhost:8080/etapi/notes/root', headers=headers) print(response.json()) ``` ### Method 2: Basic Authentication Use the ETAPI token as the password with any username: ```bash curl -X GET http://localhost:8080/etapi/notes/root \ -u "trilium:myEtapiToken123" ``` ### Method 3: Programmatic Login ```python import requests # Login to get token login_data = {'password': 'your-trilium-password'} response = requests.post('http://localhost:8080/etapi/auth/login', json=login_data) token = response.json()['authToken'] # Use token for subsequent requests headers = {'Authorization': token} notes = requests.get('http://localhost:8080/etapi/notes/root', headers=headers) ``` ## API Endpoints ### Notes #### Create Note **POST** `/etapi/create-note` Creates a new note and places it in the tree. **Request Body:** ```json { "parentNoteId": "root", "title": "My New Note", "type": "text", "content": "

This is the note content

", "notePosition": 10, "prefix": "📝", "isExpanded": true } ``` **Response (201 Created):** ```json { "note": { "noteId": "evnnmvHTCgIn", "title": "My New Note", "type": "text", "mime": "text/html", "isProtected": false, "dateCreated": "2024-01-15 10:30:00.000+0100", "dateModified": "2024-01-15 10:30:00.000+0100", "utcDateCreated": "2024-01-15 09:30:00.000Z", "utcDateModified": "2024-01-15 09:30:00.000Z" }, "branch": { "branchId": "ibhg4WxTdULk", "noteId": "evnnmvHTCgIn", "parentNoteId": "root", "prefix": "📝", "notePosition": 10, "isExpanded": true, "utcDateModified": "2024-01-15 09:30:00.000Z" } } ``` **Python Example:** ```python import requests import json def create_note(parent_id, title, content, note_type="text"): url = "http://localhost:8080/etapi/create-note" headers = { 'Authorization': 'your-token', 'Content-Type': 'application/json' } data = { "parentNoteId": parent_id, "title": title, "type": note_type, "content": content } response = requests.post(url, headers=headers, json=data) if response.status_code == 201: return response.json() else: raise Exception(f"Failed to create note: {response.text}") # Usage new_note = create_note("root", "Meeting Notes", "

Discussion points:

") print(f"Created note with ID: {new_note['note']['noteId']}") ``` #### Get Note by ID **GET** `/etapi/notes/{noteId}` **cURL Example:** ```bash curl -X GET http://localhost:8080/etapi/notes/evnnmvHTCgIn \ -H "Authorization: your-token" ``` **Response:** ```json { "noteId": "evnnmvHTCgIn", "title": "My Note", "type": "text", "mime": "text/html", "isProtected": false, "attributes": [ { "attributeId": "abc123", "noteId": "evnnmvHTCgIn", "type": "label", "name": "todo", "value": "", "position": 10, "isInheritable": false } ], "parentNoteIds": ["root"], "childNoteIds": ["child1", "child2"], "dateCreated": "2024-01-15 10:30:00.000+0100", "dateModified": "2024-01-15 14:20:00.000+0100", "utcDateCreated": "2024-01-15 09:30:00.000Z", "utcDateModified": "2024-01-15 13:20:00.000Z" } ``` #### Update Note **PATCH** `/etapi/notes/{noteId}` **Request Body:** ```json { "title": "Updated Title", "type": "text", "mime": "text/html" } ``` **JavaScript Example:** ```javascript async function updateNote(noteId, updates) { const response = await fetch(`http://localhost:8080/etapi/notes/${noteId}`, { method: 'PATCH', headers: { 'Authorization': 'your-token', 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); if (!response.ok) { throw new Error(`Failed to update note: ${response.statusText}`); } return response.json(); } // Usage updateNote('evnnmvHTCgIn', { title: 'New Title' }) .then(note => console.log('Updated note:', note)) .catch(err => console.error('Error:', err)); ``` #### Delete Note **DELETE** `/etapi/notes/{noteId}` ```bash curl -X DELETE http://localhost:8080/etapi/notes/evnnmvHTCgIn \ -H "Authorization: your-token" ``` #### Get Note Content **GET** `/etapi/notes/{noteId}/content` Returns the content of a note. ```python import requests def get_note_content(note_id, token): url = f"http://localhost:8080/etapi/notes/{note_id}/content" headers = {'Authorization': token} response = requests.get(url, headers=headers) return response.text # Returns HTML or text content content = get_note_content('evnnmvHTCgIn', 'your-token') print(content) ``` #### Update Note Content **PUT** `/etapi/notes/{noteId}/content` ```python def update_note_content(note_id, content, token): url = f"http://localhost:8080/etapi/notes/{note_id}/content" headers = { 'Authorization': token, 'Content-Type': 'text/plain' } response = requests.put(url, headers=headers, data=content) return response.status_code == 204 # Update with HTML content html_content = "

Updated Content

New paragraph

" success = update_note_content('evnnmvHTCgIn', html_content, 'your-token') ``` ### Search #### Search Notes **GET** `/etapi/notes` Search for notes using Trilium's search syntax. **Query Parameters:** - `search` (required): Search query string - `fastSearch`: Enable fast search (default: false) - `includeArchivedNotes`: Include archived notes (default: false) - `ancestorNoteId`: Search within subtree - `ancestorDepth`: Depth constraint (e.g., "eq1", "lt4", "gt2") - `orderBy`: Property to order by - `orderDirection`: "asc" or "desc" - `limit`: Maximum number of results - `debug`: Include debug information **Examples:** ```python # Full-text search def search_notes(query, token, **kwargs): url = "http://localhost:8080/etapi/notes" headers = {'Authorization': token} params = {'search': query, **kwargs} response = requests.get(url, headers=headers, params=params) return response.json() # Search for keyword results = search_notes("project management", token) # Search with label results = search_notes("#todo", token) # Search for exact phrase results = search_notes('"exact phrase"', token) # Complex search with ordering and limit results = search_notes( "type:text #important", token, orderBy="dateModified", orderDirection="desc", limit=10 ) # Search in subtree results = search_notes( "API", token, ancestorNoteId="documentation_root", ancestorDepth="lt5" ) ``` **cURL Examples:** ```bash # Simple keyword search curl -G "http://localhost:8080/etapi/notes" \ -H "Authorization: your-token" \ --data-urlencode "search=javascript" # Search with multiple criteria curl -G "http://localhost:8080/etapi/notes" \ -H "Authorization: your-token" \ --data-urlencode "search=#todo #priority" \ --data-urlencode "orderBy=dateCreated" \ --data-urlencode "orderDirection=desc" \ --data-urlencode "limit=20" ``` ### Attributes #### Create Attribute **POST** `/etapi/attributes` ```json { "noteId": "evnnmvHTCgIn", "type": "label", "name": "priority", "value": "high", "position": 10, "isInheritable": false } ``` **Python Example:** ```python def add_label(note_id, name, value="", inheritable=False): url = "http://localhost:8080/etapi/attributes" headers = { 'Authorization': 'your-token', 'Content-Type': 'application/json' } data = { "noteId": note_id, "type": "label", "name": name, "value": value, "isInheritable": inheritable } response = requests.post(url, headers=headers, json=data) return response.json() # Add a todo label add_label("evnnmvHTCgIn", "todo") # Add a priority label with value add_label("evnnmvHTCgIn", "priority", "high") ``` #### Get Attribute **GET** `/etapi/attributes/{attributeId}` #### Update Attribute **PATCH** `/etapi/attributes/{attributeId}` Only `value` and `position` can be updated for labels, only `position` for relations. ```javascript async function updateAttributeValue(attributeId, newValue) { const response = await fetch(`http://localhost:8080/etapi/attributes/${attributeId}`, { method: 'PATCH', headers: { 'Authorization': 'your-token', 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newValue }) }); return response.json(); } ``` #### Delete Attribute **DELETE** `/etapi/attributes/{attributeId}` ### Branches #### Create Branch (Clone Note) **POST** `/etapi/branches` Creates a branch (clone) of a note to another location. ```json { "noteId": "evnnmvHTCgIn", "parentNoteId": "targetParent", "prefix": "Clone: ", "notePosition": 10 } ``` **Python Example:** ```python def clone_note(note_id, new_parent_id, prefix=""): url = "http://localhost:8080/etapi/branches" headers = { 'Authorization': 'your-token', 'Content-Type': 'application/json' } data = { "noteId": note_id, "parentNoteId": new_parent_id, "prefix": prefix } response = requests.post(url, headers=headers, json=data) return response.json() # Clone a note to multiple locations clone_note("templateNote", "project1", "Task: ") clone_note("templateNote", "project2", "Task: ") ``` #### Update Branch **PATCH** `/etapi/branches/{branchId}` Only `prefix` and `notePosition` can be updated. ```bash curl -X PATCH http://localhost:8080/etapi/branches/branchId123 \ -H "Authorization: your-token" \ -H "Content-Type: application/json" \ -d '{"notePosition": 5, "prefix": "📌 "}' ``` #### Delete Branch **DELETE** `/etapi/branches/{branchId}` Deletes a branch. If this is the last branch of a note, the note is deleted too. ### Attachments #### Create Attachment **POST** `/etapi/attachments` ```json { "ownerId": "evnnmvHTCgIn", "role": "file", "mime": "application/pdf", "title": "document.pdf", "content": "base64-encoded-content", "position": 10 } ``` **Python Example with File Upload:** ```python import base64 def upload_attachment(note_id, file_path, title=None): with open(file_path, 'rb') as f: content = base64.b64encode(f.read()).decode('utf-8') import mimetypes mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' if title is None: import os title = os.path.basename(file_path) url = "http://localhost:8080/etapi/attachments" headers = { 'Authorization': 'your-token', 'Content-Type': 'application/json' } data = { "ownerId": note_id, "role": "file", "mime": mime_type, "title": title, "content": content } response = requests.post(url, headers=headers, json=data) return response.json() # Upload a PDF attachment = upload_attachment("evnnmvHTCgIn", "/path/to/document.pdf") print(f"Attachment ID: {attachment['attachmentId']}") ``` #### Get Attachment Content **GET** `/etapi/attachments/{attachmentId}/content` ```python def download_attachment(attachment_id, output_path): url = f"http://localhost:8080/etapi/attachments/{attachment_id}/content" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) with open(output_path, 'wb') as f: f.write(response.content) return output_path # Download attachment download_attachment("attachId123", "/tmp/downloaded.pdf") ``` ### Special Notes #### Get Inbox Note **GET** `/etapi/inbox/{date}` Gets or creates an inbox note for the specified date. ```python from datetime import date def get_inbox_note(target_date=None): if target_date is None: target_date = date.today() date_str = target_date.strftime('%Y-%m-%d') url = f"http://localhost:8080/etapi/inbox/{date_str}" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() # Get today's inbox inbox = get_inbox_note() print(f"Inbox note ID: {inbox['noteId']}") ``` #### Calendar Notes **Day Note:** ```python def get_day_note(date_str): url = f"http://localhost:8080/etapi/calendar/days/{date_str}" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() day_note = get_day_note("2024-01-15") ``` **Week Note:** ```python def get_week_note(date_str): url = f"http://localhost:8080/etapi/calendar/weeks/{date_str}" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() week_note = get_week_note("2024-01-15") ``` **Month Note:** ```python def get_month_note(month_str): url = f"http://localhost:8080/etapi/calendar/months/{month_str}" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() month_note = get_month_note("2024-01") ``` **Year Note:** ```python def get_year_note(year): url = f"http://localhost:8080/etapi/calendar/years/{year}" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() year_note = get_year_note("2024") ``` ### Import/Export #### Export Note Subtree **GET** `/etapi/notes/{noteId}/export` Exports a note subtree as a ZIP file. **Query Parameters:** - `format`: "html" (default) or "markdown" ```python def export_subtree(note_id, output_file, format="html"): url = f"http://localhost:8080/etapi/notes/{note_id}/export" headers = {'Authorization': 'your-token'} params = {'format': format} response = requests.get(url, headers=headers, params=params) with open(output_file, 'wb') as f: f.write(response.content) return output_file # Export entire database export_subtree("root", "backup.zip") # Export specific subtree as markdown export_subtree("projectNoteId", "project.zip", format="markdown") ``` #### Import ZIP **POST** `/etapi/notes/{noteId}/import` Imports a ZIP file into a note. ```python def import_zip(parent_note_id, zip_file_path): url = f"http://localhost:8080/etapi/notes/{parent_note_id}/import" headers = {'Authorization': 'your-token'} with open(zip_file_path, 'rb') as f: files = {'file': f} response = requests.post(url, headers=headers, files=files) return response.json() # Import backup imported = import_zip("root", "backup.zip") print(f"Imported note ID: {imported['note']['noteId']}") ``` ### Utility Endpoints #### Create Note Revision **POST** `/etapi/notes/{noteId}/revision` Forces creation of a revision for the specified note. ```bash curl -X POST http://localhost:8080/etapi/notes/evnnmvHTCgIn/revision \ -H "Authorization: your-token" ``` #### Refresh Note Ordering **POST** `/etapi/refresh-note-ordering/{parentNoteId}` Updates note ordering in connected clients after changing positions. ```python def reorder_children(parent_id, note_positions): """ note_positions: dict of {noteId: position} """ headers = { 'Authorization': 'your-token', 'Content-Type': 'application/json' } # Update each branch position for note_id, position in note_positions.items(): # Get the branch ID first note = requests.get( f"http://localhost:8080/etapi/notes/{note_id}", headers=headers ).json() for branch_id in note['parentBranchIds']: branch = requests.get( f"http://localhost:8080/etapi/branches/{branch_id}", headers=headers ).json() if branch['parentNoteId'] == parent_id: # Update position requests.patch( f"http://localhost:8080/etapi/branches/{branch_id}", headers=headers, json={'notePosition': position} ) # Refresh ordering requests.post( f"http://localhost:8080/etapi/refresh-note-ordering/{parent_id}", headers=headers ) # Reorder notes reorder_children("parentId", { "note1": 10, "note2": 20, "note3": 30 }) ``` #### Get App Info **GET** `/etapi/app-info` Returns information about the Trilium instance. ```python def get_app_info(): url = "http://localhost:8080/etapi/app-info" headers = {'Authorization': 'your-token'} response = requests.get(url, headers=headers) return response.json() info = get_app_info() print(f"Trilium version: {info['appVersion']}") print(f"Database version: {info['dbVersion']}") print(f"Data directory: {info['dataDirectory']}") ``` #### Create Backup **PUT** `/etapi/backup/{backupName}` Creates a database backup. ```bash curl -X PUT http://localhost:8080/etapi/backup/daily \ -H "Authorization: your-token" ``` This creates a backup file named `backup-daily.db` in the data directory. ## Common Use Cases ### 1. Daily Journal Entry ```python from datetime import date import requests class TriliumJournal: def __init__(self, base_url, token): self.base_url = base_url self.headers = {'Authorization': token} def create_journal_entry(self, content, tags=[]): # Get today's day note today = date.today().strftime('%Y-%m-%d') day_note_url = f"{self.base_url}/calendar/days/{today}" day_note = requests.get(day_note_url, headers=self.headers).json() # Create entry entry_data = { "parentNoteId": day_note['noteId'], "title": f"Entry - {date.today().strftime('%H:%M')}", "type": "text", "content": content } response = requests.post( f"{self.base_url}/create-note", headers={**self.headers, 'Content-Type': 'application/json'}, json=entry_data ) entry = response.json() # Add tags for tag in tags: self.add_tag(entry['note']['noteId'], tag) return entry def add_tag(self, note_id, tag_name): attr_data = { "noteId": note_id, "type": "label", "name": tag_name, "value": "" } requests.post( f"{self.base_url}/attributes", headers={**self.headers, 'Content-Type': 'application/json'}, json=attr_data ) # Usage journal = TriliumJournal("http://localhost:8080/etapi", "your-token") entry = journal.create_journal_entry( "

Today's meeting went well. Key decisions:

", tags=["meeting", "important"] ) ``` ### 2. Task Management System ```python class TriliumTaskManager: def __init__(self, base_url, token): self.base_url = base_url self.headers = {'Authorization': token} self.task_parent_id = self.get_or_create_task_root() def get_or_create_task_root(self): # Search for existing task root search_url = f"{self.base_url}/notes" params = {'search': '#taskRoot'} response = requests.get(search_url, headers=self.headers, params=params) results = response.json()['results'] if results: return results[0]['noteId'] # Create task root data = { "parentNoteId": "root", "title": "Tasks", "type": "text", "content": "

Task Management System

" } response = requests.post( f"{self.base_url}/create-note", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) note_id = response.json()['note']['noteId'] # Add taskRoot label self.add_label(note_id, "taskRoot") return note_id def create_task(self, title, description, priority="medium", due_date=None): data = { "parentNoteId": self.task_parent_id, "title": title, "type": "text", "content": f"

{description}

" } response = requests.post( f"{self.base_url}/create-note", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) task = response.json() task_id = task['note']['noteId'] # Add task attributes self.add_label(task_id, "task") self.add_label(task_id, "todoStatus", "todo") self.add_label(task_id, "priority", priority) if due_date: self.add_label(task_id, "dueDate", due_date) return task def get_tasks(self, status=None): if status: search = f"#task #todoStatus={status}" else: search = "#task" params = { 'search': search, 'ancestorNoteId': self.task_parent_id } response = requests.get( f"{self.base_url}/notes", headers=self.headers, params=params ) return response.json()['results'] def complete_task(self, task_id): # Find the todoStatus attribute note = requests.get( f"{self.base_url}/notes/{task_id}", headers=self.headers ).json() for attr in note['attributes']: if attr['name'] == 'todoStatus': # Update status requests.patch( f"{self.base_url}/attributes/{attr['attributeId']}", headers={**self.headers, 'Content-Type': 'application/json'}, json={'value': 'done'} ) break def add_label(self, note_id, name, value=""): data = { "noteId": note_id, "type": "label", "name": name, "value": value } requests.post( f"{self.base_url}/attributes", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) # Usage tasks = TriliumTaskManager("http://localhost:8080/etapi", "your-token") # Create tasks task1 = tasks.create_task( "Review API documentation", "Check for completeness and accuracy", priority="high", due_date="2024-01-20" ) task2 = tasks.create_task( "Update client library", "Add new ETAPI endpoints", priority="medium" ) # List pending tasks pending = tasks.get_tasks(status="todo") for task in pending: print(f"- {task['title']}") # Complete a task tasks.complete_task(task1['note']['noteId']) ``` ### 3. Knowledge Base Builder ```python class KnowledgeBase: def __init__(self, base_url, token): self.base_url = base_url self.headers = {'Authorization': token} def create_article(self, category, title, content, tags=[]): # Find or create category category_id = self.get_or_create_category(category) # Create article data = { "parentNoteId": category_id, "title": title, "type": "text", "content": content } response = requests.post( f"{self.base_url}/create-note", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) article = response.json() article_id = article['note']['noteId'] # Add tags for tag in tags: self.add_label(article_id, tag) # Add article label self.add_label(article_id, "article") return article def get_or_create_category(self, name): # Search for existing category params = {'search': f'#category #categoryName={name}'} response = requests.get( f"{self.base_url}/notes", headers=self.headers, params=params ) results = response.json()['results'] if results: return results[0]['noteId'] # Create new category data = { "parentNoteId": "root", "title": name, "type": "text", "content": f"

{name}

" } response = requests.post( f"{self.base_url}/create-note", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) category_id = response.json()['note']['noteId'] self.add_label(category_id, "category") self.add_label(category_id, "categoryName", name) return category_id def search_articles(self, query): params = { 'search': f'#article {query}', 'orderBy': 'relevancy' } response = requests.get( f"{self.base_url}/notes", headers=self.headers, params=params ) return response.json()['results'] def add_label(self, note_id, name, value=""): data = { "noteId": note_id, "type": "label", "name": name, "value": value } requests.post( f"{self.base_url}/attributes", headers={**self.headers, 'Content-Type': 'application/json'}, json=data ) # Usage kb = KnowledgeBase("http://localhost:8080/etapi", "your-token") # Add articles article = kb.create_article( category="Python", title="Working with REST APIs", content="""

Introduction

REST APIs are fundamental to modern web development...

Best Practices

""", tags=["api", "rest", "tutorial"] ) # Search articles results = kb.search_articles("REST API") for article in results: print(f"Found: {article['title']}") ``` ## Client Library Examples ### JavaScript/TypeScript Client ```typescript class TriliumClient { private baseUrl: string; private token: string; constructor(baseUrl: string, token: string) { this.baseUrl = baseUrl; this.token = token; } private async request( endpoint: string, options: RequestInit = {} ): Promise { const url = `${this.baseUrl}${endpoint}`; const headers = { 'Authorization': this.token, 'Content-Type': 'application/json', ...options.headers }; const response = await fetch(url, { ...options, headers }); if (!response.ok) { const error = await response.json(); throw new Error(`API Error: ${error.message}`); } if (response.status === 204) { return null; } return response.json(); } async getNote(noteId: string) { return this.request(`/notes/${noteId}`); } async createNote(data: any) { return this.request('/create-note', { method: 'POST', body: JSON.stringify(data) }); } async updateNote(noteId: string, updates: any) { return this.request(`/notes/${noteId}`, { method: 'PATCH', body: JSON.stringify(updates) }); } async deleteNote(noteId: string) { return this.request(`/notes/${noteId}`, { method: 'DELETE' }); } async searchNotes(query: string, options: any = {}) { const params = new URLSearchParams({ search: query, ...options }); return this.request(`/notes?${params}`); } async addAttribute(noteId: string, type: string, name: string, value = '') { return this.request('/attributes', { method: 'POST', body: JSON.stringify({ noteId, type, name, value }) }); } } // Usage const client = new TriliumClient('http://localhost:8080/etapi', 'your-token'); // Create a note const note = await client.createNote({ parentNoteId: 'root', title: 'New Note', type: 'text', content: '

Content

' }); // Search notes const results = await client.searchNotes('#todo', { orderBy: 'dateModified', orderDirection: 'desc', limit: 10 }); // Add a label await client.addAttribute(note.note.noteId, 'label', 'important'); ``` ### Python Client Class ```python import requests from typing import Optional, Dict, List, Any from datetime import datetime import json class TriliumETAPI: """Python client for Trilium ETAPI""" def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip('/') self.session = requests.Session() self.session.headers.update({ 'Authorization': token, 'Content-Type': 'application/json' }) def _request(self, method: str, endpoint: str, **kwargs) -> Any: """Make API request with error handling""" url = f"{self.base_url}{endpoint}" try: response = self.session.request(method, url, **kwargs) response.raise_for_status() if response.status_code == 204: return None return response.json() if response.content else None except requests.exceptions.HTTPError as e: if response.text: try: error = response.json() raise Exception(f"API Error {error.get('code')}: {error.get('message')}") except json.JSONDecodeError: raise Exception(f"HTTP {response.status_code}: {response.text}") raise e # Note operations def create_note( self, parent_note_id: str, title: str, content: str, note_type: str = "text", **kwargs ) -> Dict: """Create a new note""" data = { "parentNoteId": parent_note_id, "title": title, "type": note_type, "content": content, **kwargs } return self._request('POST', '/create-note', json=data) def get_note(self, note_id: str) -> Dict: """Get note by ID""" return self._request('GET', f'/notes/{note_id}') def update_note(self, note_id: str, updates: Dict) -> Dict: """Update note properties""" return self._request('PATCH', f'/notes/{note_id}', json=updates) def delete_note(self, note_id: str) -> None: """Delete a note""" self._request('DELETE', f'/notes/{note_id}') def get_note_content(self, note_id: str) -> str: """Get note content""" response = self.session.get(f"{self.base_url}/notes/{note_id}/content") response.raise_for_status() return response.text def update_note_content(self, note_id: str, content: str) -> None: """Update note content""" headers = {'Content-Type': 'text/plain'} self.session.put( f"{self.base_url}/notes/{note_id}/content", data=content, headers=headers ).raise_for_status() # Search def search_notes( self, query: str, fast_search: bool = False, include_archived: bool = False, ancestor_note_id: Optional[str] = None, order_by: Optional[str] = None, order_direction: str = "asc", limit: Optional[int] = None ) -> List[Dict]: """Search for notes""" params = { 'search': query, 'fastSearch': fast_search, 'includeArchivedNotes': include_archived } if ancestor_note_id: params['ancestorNoteId'] = ancestor_note_id if order_by: params['orderBy'] = order_by params['orderDirection'] = order_direction if limit: params['limit'] = limit result = self._request('GET', '/notes', params=params) return result.get('results', []) # Attributes def add_label( self, note_id: str, name: str, value: str = "", inheritable: bool = False ) -> Dict: """Add a label to a note""" data = { "noteId": note_id, "type": "label", "name": name, "value": value, "isInheritable": inheritable } return self._request('POST', '/attributes', json=data) def add_relation( self, note_id: str, name: str, target_note_id: str, inheritable: bool = False ) -> Dict: """Add a relation to a note""" data = { "noteId": note_id, "type": "relation", "name": name, "value": target_note_id, "isInheritable": inheritable } return self._request('POST', '/attributes', json=data) def update_attribute(self, attribute_id: str, updates: Dict) -> Dict: """Update an attribute""" return self._request('PATCH', f'/attributes/{attribute_id}', json=updates) def delete_attribute(self, attribute_id: str) -> None: """Delete an attribute""" self._request('DELETE', f'/attributes/{attribute_id}') # Branches def clone_note( self, note_id: str, parent_note_id: str, prefix: str = "" ) -> Dict: """Clone a note to another location""" data = { "noteId": note_id, "parentNoteId": parent_note_id, "prefix": prefix } return self._request('POST', '/branches', json=data) def move_note( self, note_id: str, new_parent_id: str ) -> None: """Move a note to a new parent""" # Get current branches note = self.get_note(note_id) # Delete old branches for branch_id in note['parentBranchIds']: self._request('DELETE', f'/branches/{branch_id}') # Create new branch self.clone_note(note_id, new_parent_id) # Special notes def get_inbox(self, date: Optional[datetime] = None) -> Dict: """Get inbox note for a date""" if date is None: date = datetime.now() date_str = date.strftime('%Y-%m-%d') return self._request('GET', f'/inbox/{date_str}') def get_day_note(self, date: Optional[datetime] = None) -> Dict: """Get day note for a date""" if date is None: date = datetime.now() date_str = date.strftime('%Y-%m-%d') return self._request('GET', f'/calendar/days/{date_str}') # Utility def get_app_info(self) -> Dict: """Get application information""" return self._request('GET', '/app-info') def create_backup(self, name: str) -> None: """Create a backup""" self._request('PUT', f'/backup/{name}') def export_subtree( self, note_id: str, format: str = "html" ) -> bytes: """Export note subtree as ZIP""" params = {'format': format} response = self.session.get( f"{self.base_url}/notes/{note_id}/export", params=params ) response.raise_for_status() return response.content # Example usage if __name__ == "__main__": # Initialize client api = TriliumETAPI("http://localhost:8080/etapi", "your-token") # Create a note note = api.create_note( parent_note_id="root", title="API Test Note", content="

Created via Python client

" ) print(f"Created note: {note['note']['noteId']}") # Add labels api.add_label(note['note']['noteId'], "test") api.add_label(note['note']['noteId'], "priority", "high") # Search results = api.search_notes("#test", limit=10) for result in results: print(f"Found: {result['title']}") # Export backup backup_data = api.export_subtree("root") with open("backup.zip", "wb") as f: f.write(backup_data) ``` ## Rate Limiting and Best Practices ### Rate Limiting ETAPI implements rate limiting for authentication endpoints: - **Login endpoint**: Maximum 10 requests per IP per hour - **Other endpoints**: No specific rate limits, but excessive requests may be throttled ### Best Practices #### 1. Connection Pooling Reuse HTTP connections for better performance: ```python import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, backoff_factor=0.3, status_forcelist=[500, 502, 503, 504] ) adapter = HTTPAdapter(max_retries=retry) session.mount('http://', adapter) session.mount('https://', adapter) ``` #### 2. Batch Operations When possible, batch multiple operations: ```python def batch_create_notes(notes_data): """Create multiple notes efficiently""" created_notes = [] for data in notes_data: note = api.create_note(**data) created_notes.append(note) # Add small delay to avoid overwhelming server time.sleep(0.1) return created_notes ``` #### 3. Error Handling Implement robust error handling: ```python import time from typing import Callable, Any def retry_on_error( func: Callable, max_retries: int = 3, backoff_factor: float = 1.0 ) -> Any: """Retry function with exponential backoff""" for attempt in range(max_retries): try: return func() except requests.exceptions.RequestException as e: if attempt == max_retries - 1: raise wait_time = backoff_factor * (2 ** attempt) print(f"Request failed, retrying in {wait_time}s...") time.sleep(wait_time) # Usage note = retry_on_error( lambda: api.create_note("root", "Title", "Content") ) ``` #### 4. Caching Cache frequently accessed data: ```python from functools import lru_cache from datetime import datetime, timedelta class CachedTriliumClient(TriliumETAPI): def __init__(self, base_url: str, token: str): super().__init__(base_url, token) self._cache = {} self._cache_times = {} def get_note_cached(self, note_id: str, max_age: int = 300): """Get note with caching (max_age in seconds)""" cache_key = f"note:{note_id}" if cache_key in self._cache: cache_time = self._cache_times[cache_key] if datetime.now() - cache_time < timedelta(seconds=max_age): return self._cache[cache_key] note = self.get_note(note_id) self._cache[cache_key] = note self._cache_times[cache_key] = datetime.now() return note ``` #### 5. Pagination for Large Results Handle large result sets with pagination: ```python def search_all_notes(api: TriliumETAPI, query: str, batch_size: int = 100): """Search with pagination for large result sets""" all_results = [] offset = 0 while True: results = api.search_notes( query, limit=batch_size, order_by="dateCreated" ) if not results: break all_results.extend(results) if len(results) < batch_size: break # Use the last note's date as reference for next batch last_date = results[-1]['dateCreated'] query_with_date = f"{query} dateCreated>{last_date}" return all_results ``` ## Migration from Internal API ### Key Differences | Aspect | Internal API | ETAPI | |--------|-------------|--------| | **Purpose** | Trilium client communication | External integrations | | **Authentication** | Session-based | Token-based | | **Stability** | May change between versions | Stable interface | | **CSRF Protection** | Required | Not required | | **WebSocket** | Supported | Not available | | **Documentation** | Limited | Comprehensive | ### Migration Steps 1. **Replace Authentication** ```python # Old (Internal API) session = requests.Session() session.post('/api/login', data={'password': 'pass'}) # New (ETAPI) headers = {'Authorization': 'etapi-token'} ``` 2. **Update Endpoints** ```python # Old /api/notes/getNoteById/noteId # New /etapi/notes/noteId ``` 3. **Adjust Request/Response Format** ```python # Old (may vary) response = session.post('/api/notes/new', json={ 'parentNoteId': 'root', 'title': 'Title' }) # New (standardized) response = requests.post('/etapi/create-note', headers=headers, json={ 'parentNoteId': 'root', 'title': 'Title', 'type': 'text', 'content': '' } ) ``` ## Error Handling ### Common Error Codes | Status | Code | Description | Resolution | |--------|------|-------------|------------| | 400 | BAD_REQUEST | Invalid request format | Check request body and parameters | | 401 | UNAUTHORIZED | Invalid or missing token | Verify authentication token | | 404 | NOTE_NOT_FOUND | Note doesn't exist | Check note ID | | 404 | BRANCH_NOT_FOUND | Branch doesn't exist | Verify branch ID | | 400 | NOTE_IS_PROTECTED | Cannot modify protected note | Unlock protected session first | | 429 | TOO_MANY_REQUESTS | Rate limit exceeded | Wait before retrying | | 500 | INTERNAL_ERROR | Server error | Report issue, check logs | ### Error Response Format ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Note title cannot be empty" } ``` ### Handling Errors in Code ```python class ETAPIError(Exception): def __init__(self, status, code, message): self.status = status self.code = code self.message = message super().__init__(f"{code}: {message}") def handle_api_response(response): if response.status_code >= 400: try: error = response.json() raise ETAPIError( error.get('status'), error.get('code'), error.get('message') ) except json.JSONDecodeError: raise ETAPIError( response.status_code, 'UNKNOWN_ERROR', response.text ) return response.json() if response.content else None # Usage try: response = requests.get( 'http://localhost:8080/etapi/notes/invalid', headers={'Authorization': 'token'} ) note = handle_api_response(response) except ETAPIError as e: if e.code == 'NOTE_NOT_FOUND': print("Note doesn't exist") else: print(f"API Error: {e.message}") ``` ## Performance Considerations ### 1. Minimize API Calls ```python # Bad: Multiple calls note = api.get_note(note_id) for child_id in note['childNoteIds']: child = api.get_note(child_id) # N+1 problem process(child) # Good: Batch processing note = api.get_note(note_id) children = api.search_notes( f"note.parents.noteId={note_id}", limit=1000 ) for child in children: process(child) ``` ### 2. Use Appropriate Search Depth ```python # Limit search depth for better performance results = api.search_notes( "keyword", ancestor_note_id="root", ancestor_depth="lt3" # Only search 3 levels deep ) ``` ### 3. Content Compression Enable gzip compression for large responses: ```python session = requests.Session() session.headers.update({ 'Authorization': 'token', 'Accept-Encoding': 'gzip, deflate' }) ``` ### 4. Async Operations Use async requests for parallel operations: ```python import asyncio import aiohttp class AsyncTriliumClient: def __init__(self, base_url: str, token: str): self.base_url = base_url self.headers = {'Authorization': token} async def get_note(self, session, note_id): url = f"{self.base_url}/notes/{note_id}" async with session.get(url, headers=self.headers) as response: return await response.json() async def get_multiple_notes(self, note_ids): async with aiohttp.ClientSession() as session: tasks = [self.get_note(session, nid) for nid in note_ids] return await asyncio.gather(*tasks) # Usage client = AsyncTriliumClient("http://localhost:8080/etapi", "token") notes = asyncio.run(client.get_multiple_notes(['id1', 'id2', 'id3'])) ``` ### 5. Database Optimization For bulk operations, consider: - Creating notes in batches - Using transactions (via backup/restore) - Indexing frequently searched attributes ## Security Considerations ### Token Management - Store tokens securely (environment variables, key vaults) - Rotate tokens regularly - Use separate tokens for different applications - Never commit tokens to version control ```python import os from dotenv import load_dotenv load_dotenv() # Load token from environment TOKEN = os.getenv('TRILIUM_ETAPI_TOKEN') if not TOKEN: raise ValueError("TRILIUM_ETAPI_TOKEN not set") api = TriliumETAPI("http://localhost:8080/etapi", TOKEN) ``` ### HTTPS Usage Always use HTTPS in production: ```python # Development dev_api = TriliumETAPI("http://localhost:8080/etapi", token) # Production prod_api = TriliumETAPI("https://notes.example.com/etapi", token) ``` ### Input Validation Sanitize user input before sending to API: ```python import html import re def sanitize_html(content: str) -> str: """Basic HTML sanitization""" # Remove script tags content = re.sub(r']*>.*?', '', content, flags=re.DOTALL) # Remove on* attributes content = re.sub(r'\s*on\w+\s*=\s*["\'][^"\']*["\']', '', content) return content def create_safe_note(title: str, content: str): safe_title = html.escape(title) safe_content = sanitize_html(content) return api.create_note( parent_note_id="root", title=safe_title, content=safe_content ) ``` ## Troubleshooting ### Connection Issues ```python # Test connection def test_connection(base_url, token): try: api = TriliumETAPI(base_url, token) info = api.get_app_info() print(f"Connected to Trilium {info['appVersion']}") return True except Exception as e: print(f"Connection failed: {e}") return False # Debug mode import logging logging.basicConfig(level=logging.DEBUG) ``` ### Common Issues and Solutions | Issue | Cause | Solution | |-------|-------|----------| | 401 Unauthorized | Invalid token | Regenerate token in Trilium Options | | Connection refused | Server not running | Start Trilium server | | CORS errors | Cross-origin requests | Configure CORS in Trilium settings | | Timeout errors | Large operations | Increase timeout, use async | | 404 Not Found | Wrong endpoint | Check ETAPI prefix in URL | | Protected note error | Note is encrypted | Enter protected session first | ## Additional Resources - [Trilium GitHub Repository](https://github.com/TriliumNext/Trilium) - [OpenAPI Specification](/apps/server/src/assets/etapi.openapi.yaml) - [Trilium Search Documentation](https://triliumnext.github.io/Docs/Wiki/search.html) - [Community Forum](https://github.com/TriliumNext/Trilium/discussions)