Files
Trilium/docs/Developer Guide/API Documentation/ETAPI Complete Guide.md

1898 lines
48 KiB
Markdown
Raw Normal View History

# 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: <your-token>
```
**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": "<p>This is the note content</p>",
"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", "<p>Discussion points:</p><ul><li>Item 1</li></ul>")
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 = "<h1>Updated Content</h1><p>New paragraph</p>"
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(
"<p>Today's meeting went well. Key decisions:</p><ul><li>Item 1</li></ul>",
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": "<p>Task Management System</p>"
}
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"<p>{description}</p>"
}
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"<h1>{name}</h1>"
}
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="""
<h2>Introduction</h2>
<p>REST APIs are fundamental to modern web development...</p>
<h2>Best Practices</h2>
<ul>
<li>Use proper HTTP methods</li>
<li>Handle errors gracefully</li>
<li>Implement retry logic</li>
</ul>
""",
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<any> {
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: '<p>Content</p>'
});
// 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="<p>Created via Python client</p>"
)
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'<script[^>]*>.*?</script>', '', 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)