48 KiB
Vendored
ETAPI Complete Guide
Table of Contents
- Introduction
- Authentication Setup
- API Endpoints
- Common Use Cases
- Client Library Examples
- Rate Limiting and Best Practices
- Migration from Internal API
- Error Handling
- 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
- Open Trilium Notes
- Navigate to Options → ETAPI
- Click "Create new ETAPI token"
- Copy the generated token
Step 2: Use Token in Requests
HTTP Header:
Authorization: <your-token>
cURL Example:
curl -X GET http://localhost:8080/etapi/notes/root \
-H "Authorization: myEtapiToken123"
Python Example:
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:
curl -X GET http://localhost:8080/etapi/notes/root \
-u "trilium:myEtapiToken123"
Method 3: Programmatic Login
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:
{
"parentNoteId": "root",
"title": "My New Note",
"type": "text",
"content": "<p>This is the note content</p>",
"notePosition": 10,
"prefix": "📝",
"isExpanded": true
}
Response (201 Created):
{
"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:
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:
curl -X GET http://localhost:8080/etapi/notes/evnnmvHTCgIn \
-H "Authorization: your-token"
Response:
{
"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:
{
"title": "Updated Title",
"type": "text",
"mime": "text/html"
}
JavaScript Example:
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}
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.
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
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 stringfastSearch: Enable fast search (default: false)includeArchivedNotes: Include archived notes (default: false)ancestorNoteId: Search within subtreeancestorDepth: Depth constraint (e.g., "eq1", "lt4", "gt2")orderBy: Property to order byorderDirection: "asc" or "desc"limit: Maximum number of resultsdebug: Include debug information
Examples:
# 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:
# 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
{
"noteId": "evnnmvHTCgIn",
"type": "label",
"name": "priority",
"value": "high",
"position": 10,
"isInheritable": false
}
Python Example:
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.
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.
{
"noteId": "evnnmvHTCgIn",
"parentNoteId": "targetParent",
"prefix": "Clone: ",
"notePosition": 10
}
Python Example:
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.
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
{
"ownerId": "evnnmvHTCgIn",
"role": "file",
"mime": "application/pdf",
"title": "document.pdf",
"content": "base64-encoded-content",
"position": 10
}
Python Example with File Upload:
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
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.
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:
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:
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:
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:
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"
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.
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.
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.
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.
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.
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
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
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
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
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
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:
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:
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:
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:
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:
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
-
Replace Authentication
# Old (Internal API) session = requests.Session() session.post('/api/login', data={'password': 'pass'}) # New (ETAPI) headers = {'Authorization': 'etapi-token'} -
Update Endpoints
# Old /api/notes/getNoteById/noteId # New /etapi/notes/noteId -
Adjust Request/Response Format
# 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
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Note title cannot be empty"
}
Handling Errors in Code
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
# 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
# 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:
session = requests.Session()
session.headers.update({
'Authorization': 'token',
'Accept-Encoding': 'gzip, deflate'
})
4. Async Operations
Use async requests for parallel operations:
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
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:
# 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:
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
# 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 |