mirror of
https://github.com/zadam/trilium.git
synced 2025-10-28 16:56:34 +01:00
1898 lines
48 KiB
Markdown
1898 lines
48 KiB
Markdown
|
|
# 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)
|