Files
Trilium/docs/Developer Guide/API Documentation/API Client Libraries.md
2025-08-21 15:55:44 +00:00

67 KiB
Vendored

API Client Libraries

Table of Contents

  1. Overview
  2. JavaScript/TypeScript Client
  3. Python Client - trilium-py
  4. Go Client
  5. Ruby Client
  6. PHP Client
  7. C# Client
  8. Rust Client
  9. REST Client Best Practices
  10. Error Handling Patterns
  11. Retry Strategies
  12. Testing Client Libraries

Overview

This guide provides comprehensive examples of Trilium API client libraries in various programming languages. Each implementation follows best practices for that language while maintaining consistent functionality across all clients.

Common Features

All client libraries should implement:

  • Token-based authentication
  • CRUD operations for notes, attributes, branches, and attachments
  • Search functionality
  • Error handling with retry logic
  • Connection pooling
  • Request/response logging (optional)
  • Rate limiting support

JavaScript/TypeScript Client

// trilium-client.ts

import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';

// Types
export interface Note {
    noteId: string;
    title: string;
    type: string;
    mime: string;
    isProtected: boolean;
    attributes?: Attribute[];
    parentNoteIds?: string[];
    childNoteIds?: string[];
    dateCreated: string;
    dateModified: string;
    utcDateCreated: string;
    utcDateModified: string;
}

export interface CreateNoteParams {
    parentNoteId: string;
    title: string;
    type: string;
    content: string;
    notePosition?: number;
    prefix?: string;
    isExpanded?: boolean;
    noteId?: string;
    branchId?: string;
}

export interface Attribute {
    attributeId: string;
    noteId: string;
    type: 'label' | 'relation';
    name: string;
    value: string;
    position?: number;
    isInheritable?: boolean;
}

export interface Branch {
    branchId: string;
    noteId: string;
    parentNoteId: string;
    prefix?: string;
    notePosition?: number;
    isExpanded?: boolean;
}

export interface Attachment {
    attachmentId: string;
    ownerId: string;
    role: string;
    mime: string;
    title: string;
    position?: number;
    blobId?: string;
    dateModified?: string;
    utcDateModified?: string;
}

export interface SearchParams {
    search: string;
    fastSearch?: boolean;
    includeArchivedNotes?: boolean;
    ancestorNoteId?: string;
    ancestorDepth?: string;
    orderBy?: string;
    orderDirection?: 'asc' | 'desc';
    limit?: number;
    debug?: boolean;
}

export interface SearchResponse {
    results: Note[];
    debugInfo?: any;
}

export interface AppInfo {
    appVersion: string;
    dbVersion: number;
    syncVersion: number;
    buildDate: string;
    buildRevision: string;
    dataDirectory: string;
    clipperProtocolVersion: string;
    utcDateTime: string;
}

export interface TriliumClientConfig {
    baseUrl: string;
    token: string;
    timeout?: number;
    retryAttempts?: number;
    retryDelay?: number;
    enableLogging?: boolean;
}

// Error classes
export class TriliumError extends Error {
    constructor(
        message: string,
        public statusCode?: number,
        public code?: string,
        public details?: any
    ) {
        super(message);
        this.name = 'TriliumError';
    }
}

export class TriliumConnectionError extends TriliumError {
    constructor(message: string, details?: any) {
        super(message, undefined, 'CONNECTION_ERROR', details);
        this.name = 'TriliumConnectionError';
    }
}

export class TriliumAuthError extends TriliumError {
    constructor(message: string, details?: any) {
        super(message, 401, 'AUTH_ERROR', details);
        this.name = 'TriliumAuthError';
    }
}

// Main client class
export class TriliumClient {
    private client: AxiosInstance;
    private config: Required<TriliumClientConfig>;
    
    constructor(config: TriliumClientConfig) {
        this.config = {
            timeout: 30000,
            retryAttempts: 3,
            retryDelay: 1000,
            enableLogging: false,
            ...config
        };
        
        this.client = axios.create({
            baseURL: this.config.baseUrl,
            timeout: this.config.timeout,
            headers: {
                'Authorization': this.config.token,
                'Content-Type': 'application/json'
            }
        });
        
        this.setupInterceptors();
    }
    
    private setupInterceptors(): void {
        // Request interceptor for logging
        this.client.interceptors.request.use(
            (config) => {
                if (this.config.enableLogging) {
                    console.log(`[Trilium] ${config.method?.toUpperCase()} ${config.url}`);
                }
                return config;
            },
            (error) => Promise.reject(error)
        );
        
        // Response interceptor for error handling and retry
        this.client.interceptors.response.use(
            (response) => response,
            async (error: AxiosError) => {
                const originalRequest = error.config as AxiosRequestConfig & { _retryCount?: number };
                
                if (!originalRequest) {
                    throw new TriliumConnectionError('No request config available');
                }
                
                // Initialize retry count
                if (!originalRequest._retryCount) {
                    originalRequest._retryCount = 0;
                }
                
                // Handle different error types
                if (error.response) {
                    // Server responded with error
                    if (error.response.status === 401) {
                        throw new TriliumAuthError('Authentication failed', error.response.data);
                    }
                    
                    // Don't retry client errors (4xx)
                    if (error.response.status >= 400 && error.response.status < 500) {
                        throw new TriliumError(
                            error.response.data?.message || error.message,
                            error.response.status,
                            error.response.data?.code,
                            error.response.data
                        );
                    }
                } else if (error.request) {
                    // No response received
                    if (originalRequest._retryCount < this.config.retryAttempts) {
                        originalRequest._retryCount++;
                        
                        if (this.config.enableLogging) {
                            console.log(`[Trilium] Retry attempt ${originalRequest._retryCount}/${this.config.retryAttempts}`);
                        }
                        
                        // Wait before retry
                        await this.sleep(this.config.retryDelay * originalRequest._retryCount);
                        
                        return this.client(originalRequest);
                    }
                    
                    throw new TriliumConnectionError('No response from server', error.request);
                }
                
                throw new TriliumError(error.message);
            }
        );
    }
    
    private sleep(ms: number): Promise<void> {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    // Note operations
    async createNote(params: CreateNoteParams): Promise<{ note: Note; branch: Branch }> {
        const response = await this.client.post<{ note: Note; branch: Branch }>('/create-note', params);
        return response.data;
    }
    
    async getNote(noteId: string): Promise<Note> {
        const response = await this.client.get<Note>(`/notes/${noteId}`);
        return response.data;
    }
    
    async updateNote(noteId: string, updates: Partial<Note>): Promise<Note> {
        const response = await this.client.patch<Note>(`/notes/${noteId}`, updates);
        return response.data;
    }
    
    async deleteNote(noteId: string): Promise<void> {
        await this.client.delete(`/notes/${noteId}`);
    }
    
    async getNoteContent(noteId: string): Promise<string> {
        const response = await this.client.get(`/notes/${noteId}/content`, {
            responseType: 'text'
        });
        return response.data;
    }
    
    async updateNoteContent(noteId: string, content: string): Promise<void> {
        await this.client.put(`/notes/${noteId}/content`, content, {
            headers: { 'Content-Type': 'text/plain' }
        });
    }
    
    // Search
    async searchNotes(params: SearchParams): Promise<SearchResponse> {
        const response = await this.client.get<SearchResponse>('/notes', { params });
        return response.data;
    }
    
    // Attributes
    async createAttribute(attribute: Omit<Attribute, 'attributeId'>): Promise<Attribute> {
        const response = await this.client.post<Attribute>('/attributes', attribute);
        return response.data;
    }
    
    async updateAttribute(attributeId: string, updates: Partial<Attribute>): Promise<Attribute> {
        const response = await this.client.patch<Attribute>(`/attributes/${attributeId}`, updates);
        return response.data;
    }
    
    async deleteAttribute(attributeId: string): Promise<void> {
        await this.client.delete(`/attributes/${attributeId}`);
    }
    
    // Branches
    async createBranch(branch: Omit<Branch, 'branchId'>): Promise<Branch> {
        const response = await this.client.post<Branch>('/branches', branch);
        return response.data;
    }
    
    async updateBranch(branchId: string, updates: Partial<Branch>): Promise<Branch> {
        const response = await this.client.patch<Branch>(`/branches/${branchId}`, updates);
        return response.data;
    }
    
    async deleteBranch(branchId: string): Promise<void> {
        await this.client.delete(`/branches/${branchId}`);
    }
    
    // Attachments
    async createAttachment(attachment: {
        ownerId: string;
        role: string;
        mime: string;
        title: string;
        content: string;
        position?: number;
    }): Promise<Attachment> {
        const response = await this.client.post<Attachment>('/attachments', attachment);
        return response.data;
    }
    
    async getAttachment(attachmentId: string): Promise<Attachment> {
        const response = await this.client.get<Attachment>(`/attachments/${attachmentId}`);
        return response.data;
    }
    
    async getAttachmentContent(attachmentId: string): Promise<ArrayBuffer> {
        const response = await this.client.get(`/attachments/${attachmentId}/content`, {
            responseType: 'arraybuffer'
        });
        return response.data;
    }
    
    async deleteAttachment(attachmentId: string): Promise<void> {
        await this.client.delete(`/attachments/${attachmentId}`);
    }
    
    // Special notes
    async getInboxNote(date: string): Promise<Note> {
        const response = await this.client.get<Note>(`/inbox/${date}`);
        return response.data;
    }
    
    async getDayNote(date: string): Promise<Note> {
        const response = await this.client.get<Note>(`/calendar/days/${date}`);
        return response.data;
    }
    
    async getWeekNote(date: string): Promise<Note> {
        const response = await this.client.get<Note>(`/calendar/weeks/${date}`);
        return response.data;
    }
    
    async getMonthNote(month: string): Promise<Note> {
        const response = await this.client.get<Note>(`/calendar/months/${month}`);
        return response.data;
    }
    
    async getYearNote(year: string): Promise<Note> {
        const response = await this.client.get<Note>(`/calendar/years/${year}`);
        return response.data;
    }
    
    // Utility
    async getAppInfo(): Promise<AppInfo> {
        const response = await this.client.get<AppInfo>('/app-info');
        return response.data;
    }
    
    async createBackup(backupName: string): Promise<void> {
        await this.client.put(`/backup/${backupName}`);
    }
    
    async exportNotes(noteId: string, format: 'html' | 'markdown' = 'html'): Promise<ArrayBuffer> {
        const response = await this.client.get(`/notes/${noteId}/export`, {
            params: { format },
            responseType: 'arraybuffer'
        });
        return response.data;
    }
}

// Helper functions
export function createClient(baseUrl: string, token: string, options?: Partial<TriliumClientConfig>): TriliumClient {
    return new TriliumClient({
        baseUrl,
        token,
        ...options
    });
}

// Batch operations helper
export class TriliumBatchClient extends TriliumClient {
    async createMultipleNotes(notes: CreateNoteParams[]): Promise<Array<{ note: Note; branch: Branch }>> {
        const results = [];
        
        for (const noteParams of notes) {
            try {
                const result = await this.createNote(noteParams);
                results.push(result);
            } catch (error) {
                if (this.config.enableLogging) {
                    console.error(`Failed to create note "${noteParams.title}":`, error);
                }
                throw error;
            }
        }
        
        return results;
    }
    
    async searchAndUpdate(
        searchQuery: string,
        updateFn: (note: Note) => Partial<Note> | null
    ): Promise<Note[]> {
        const searchResults = await this.searchNotes({ search: searchQuery });
        const updatedNotes = [];
        
        for (const note of searchResults.results) {
            const updates = updateFn(note);
            if (updates) {
                const updated = await this.updateNote(note.noteId, updates);
                updatedNotes.push(updated);
            }
        }
        
        return updatedNotes;
    }
}

// Usage example
async function example() {
    const client = createClient('http://localhost:8080/etapi', 'your-token', {
        enableLogging: true,
        retryAttempts: 5
    });
    
    try {
        // Create a note
        const { note } = await client.createNote({
            parentNoteId: 'root',
            title: 'Test Note',
            type: 'text',
            content: '<p>Hello, Trilium!</p>'
        });
        
        console.log('Created note:', note.noteId);
        
        // Search for notes
        const searchResults = await client.searchNotes({
            search: '#todo',
            limit: 10,
            orderBy: 'dateModified',
            orderDirection: 'desc'
        });
        
        console.log(`Found ${searchResults.results.length} todo notes`);
        
        // Add a label
        await client.createAttribute({
            noteId: note.noteId,
            type: 'label',
            name: 'priority',
            value: 'high'
        });
        
    } catch (error) {
        if (error instanceof TriliumAuthError) {
            console.error('Authentication failed:', error.message);
        } else if (error instanceof TriliumConnectionError) {
            console.error('Connection error:', error.message);
        } else if (error instanceof TriliumError) {
            console.error(`API error (${error.statusCode}):`, error.message);
        } else {
            console.error('Unexpected error:', error);
        }
    }
}

Browser-Compatible JavaScript Client

// trilium-browser-client.js

class TriliumBrowserClient {
    constructor(baseUrl, token) {
        this.baseUrl = baseUrl.replace(/\/$/, '');
        this.token = token;
        this.headers = {
            'Authorization': token,
            'Content-Type': 'application/json'
        };
    }
    
    async request(endpoint, options = {}) {
        const url = `${this.baseUrl}${endpoint}`;
        const config = {
            headers: { ...this.headers, ...options.headers },
            ...options
        };
        
        try {
            const response = await fetch(url, config);
            
            if (!response.ok) {
                const error = await response.json().catch(() => ({}));
                throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
            }
            
            if (response.status === 204) {
                return null;
            }
            
            const contentType = response.headers.get('content-type');
            if (contentType && contentType.includes('application/json')) {
                return response.json();
            }
            
            return response.text();
        } catch (error) {
            console.error(`Request failed: ${endpoint}`, error);
            throw error;
        }
    }
    
    // Notes
    async createNote(parentNoteId, title, content, type = 'text') {
        return this.request('/create-note', {
            method: 'POST',
            body: JSON.stringify({
                parentNoteId,
                title,
                type,
                content
            })
        });
    }
    
    async getNote(noteId) {
        return this.request(`/notes/${noteId}`);
    }
    
    async updateNote(noteId, updates) {
        return this.request(`/notes/${noteId}`, {
            method: 'PATCH',
            body: JSON.stringify(updates)
        });
    }
    
    async deleteNote(noteId) {
        return this.request(`/notes/${noteId}`, {
            method: 'DELETE'
        });
    }
    
    async getNoteContent(noteId) {
        return this.request(`/notes/${noteId}/content`);
    }
    
    async updateNoteContent(noteId, content) {
        return this.request(`/notes/${noteId}/content`, {
            method: 'PUT',
            headers: { 'Content-Type': 'text/plain' },
            body: content
        });
    }
    
    // Search
    async searchNotes(query, options = {}) {
        const params = new URLSearchParams({
            search: query,
            ...options
        });
        
        return this.request(`/notes?${params}`);
    }
    
    // Attributes
    async addLabel(noteId, name, value = '') {
        return this.request('/attributes', {
            method: 'POST',
            body: JSON.stringify({
                noteId,
                type: 'label',
                name,
                value
            })
        });
    }
    
    async addRelation(noteId, name, targetNoteId) {
        return this.request('/attributes', {
            method: 'POST',
            body: JSON.stringify({
                noteId,
                type: 'relation',
                name,
                value: targetNoteId
            })
        });
    }
    
    // Special notes
    async getTodayNote() {
        const today = new Date().toISOString().split('T')[0];
        return this.request(`/calendar/days/${today}`);
    }
    
    async getInbox() {
        const today = new Date().toISOString().split('T')[0];
        return this.request(`/inbox/${today}`);
    }
}

// Usage in browser
const trilium = new TriliumBrowserClient('http://localhost:8080/etapi', 'your-token');

// Create a quick note
async function createQuickNote(title, content) {
    try {
        const inbox = await trilium.getInbox();
        const result = await trilium.createNote(inbox.noteId, title, content);
        console.log('Note created:', result.note.noteId);
        return result;
    } catch (error) {
        console.error('Failed to create note:', error);
    }
}

Python Client - trilium-py

Installation

pip install trilium-py

Complete Python Implementation

# trilium_client.py

import requests
from typing import Optional, Dict, List, Any, Union
from datetime import datetime, date
from dataclasses import dataclass, asdict
from enum import Enum
import time
import logging
from urllib.parse import urljoin
import json
import base64

# Set up logging
logger = logging.getLogger(__name__)

# Enums
class NoteType(Enum):
    TEXT = "text"
    CODE = "code"
    FILE = "file"
    IMAGE = "image"
    SEARCH = "search"
    BOOK = "book"
    RELATION_MAP = "relationMap"
    RENDER = "render"

class AttributeType(Enum):
    LABEL = "label"
    RELATION = "relation"

# Data classes
@dataclass
class Note:
    noteId: str
    title: str
    type: str
    mime: Optional[str] = None
    isProtected: bool = False
    dateCreated: Optional[str] = None
    dateModified: Optional[str] = None
    utcDateCreated: Optional[str] = None
    utcDateModified: Optional[str] = None
    parentNoteIds: Optional[List[str]] = None
    childNoteIds: Optional[List[str]] = None
    attributes: Optional[List[Dict]] = None

@dataclass
class CreateNoteRequest:
    parentNoteId: str
    title: str
    type: str
    content: str
    notePosition: Optional[int] = None
    prefix: Optional[str] = None
    isExpanded: Optional[bool] = None
    noteId: Optional[str] = None
    branchId: Optional[str] = None

@dataclass
class Attribute:
    noteId: str
    type: str
    name: str
    value: str = ""
    position: Optional[int] = None
    isInheritable: bool = False
    attributeId: Optional[str] = None

@dataclass
class Branch:
    noteId: str
    parentNoteId: str
    prefix: Optional[str] = None
    notePosition: Optional[int] = None
    isExpanded: Optional[bool] = None
    branchId: Optional[str] = None

# Exceptions
class TriliumError(Exception):
    """Base exception for Trilium API errors"""
    def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None):
        super().__init__(message)
        self.status_code = status_code
        self.details = details

class TriliumAuthError(TriliumError):
    """Authentication error"""
    pass

class TriliumNotFoundError(TriliumError):
    """Resource not found error"""
    pass

class TriliumConnectionError(TriliumError):
    """Connection error"""
    pass

# Main client class
class TriliumClient:
    """Python client for Trilium ETAPI"""
    
    def __init__(
        self,
        base_url: str,
        token: str,
        timeout: int = 30,
        retry_attempts: int = 3,
        retry_delay: float = 1.0,
        verify_ssl: bool = True,
        debug: bool = False
    ):
        self.base_url = base_url.rstrip('/')
        self.token = token
        self.timeout = timeout
        self.retry_attempts = retry_attempts
        self.retry_delay = retry_delay
        self.verify_ssl = verify_ssl
        self.debug = debug
        
        # Set up session
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': token,
            'Content-Type': 'application/json'
        })
        
        # Configure logging
        if debug:
            logging.basicConfig(level=logging.DEBUG)
        
    def _request(
        self,
        method: str,
        endpoint: str,
        json_data: Optional[Dict] = None,
        params: Optional[Dict] = None,
        data: Optional[Union[str, bytes]] = None,
        headers: Optional[Dict] = None,
        **kwargs
    ) -> Any:
        """Make HTTP request with retry logic"""
        url = urljoin(self.base_url, endpoint)
        
        # Merge headers
        req_headers = self.session.headers.copy()
        if headers:
            req_headers.update(headers)
        
        # Retry logic
        last_exception = None
        for attempt in range(self.retry_attempts):
            try:
                if self.debug:
                    logger.debug(f"[Attempt {attempt + 1}] {method} {url}")
                
                response = self.session.request(
                    method=method,
                    url=url,
                    json=json_data,
                    params=params,
                    data=data,
                    headers=req_headers,
                    timeout=self.timeout,
                    verify=self.verify_ssl,
                    **kwargs
                )
                
                # Handle different status codes
                if response.status_code == 401:
                    raise TriliumAuthError("Authentication failed", 401)
                elif response.status_code == 404:
                    raise TriliumNotFoundError("Resource not found", 404)
                elif response.status_code >= 500:
                    # Server error - retry
                    if attempt < self.retry_attempts - 1:
                        time.sleep(self.retry_delay * (attempt + 1))
                        continue
                    else:
                        response.raise_for_status()
                elif not response.ok:
                    error_data = {}
                    try:
                        error_data = response.json()
                    except:
                        pass
                    raise TriliumError(
                        error_data.get('message', f"HTTP {response.status_code}"),
                        response.status_code,
                        error_data
                    )
                
                # Parse response
                if response.status_code == 204:
                    return None
                
                content_type = response.headers.get('content-type', '')
                if 'application/json' in content_type:
                    return response.json()
                elif 'text' in content_type:
                    return response.text
                else:
                    return response.content
                    
            except requests.exceptions.ConnectionError as e:
                last_exception = e
                if attempt < self.retry_attempts - 1:
                    logger.warning(f"Connection error, retrying in {self.retry_delay * (attempt + 1)}s...")
                    time.sleep(self.retry_delay * (attempt + 1))
                else:
                    raise TriliumConnectionError(f"Connection failed after {self.retry_attempts} attempts") from e
            except requests.exceptions.Timeout as e:
                last_exception = e
                if attempt < self.retry_attempts - 1:
                    logger.warning(f"Request timeout, retrying...")
                    time.sleep(self.retry_delay * (attempt + 1))
                else:
                    raise TriliumConnectionError("Request timeout") from e
            except TriliumError:
                raise
            except Exception as e:
                raise TriliumError(f"Unexpected error: {str(e)}") from e
        
        if last_exception:
            raise TriliumConnectionError(f"Request failed after {self.retry_attempts} attempts") from last_exception
    
    # Note operations
    def create_note(
        self,
        parent_note_id: str,
        title: str,
        content: str,
        note_type: Union[str, NoteType] = NoteType.TEXT,
        **kwargs
    ) -> Dict[str, Any]:
        """Create a new note"""
        if isinstance(note_type, NoteType):
            note_type = note_type.value
        
        data = {
            'parentNoteId': parent_note_id,
            'title': title,
            'type': note_type,
            'content': content,
            **kwargs
        }
        
        return self._request('POST', '/create-note', json_data=data)
    
    def get_note(self, note_id: str) -> Note:
        """Get note by ID"""
        data = self._request('GET', f'/notes/{note_id}')
        return Note(**data)
    
    def update_note(self, note_id: str, **updates) -> Note:
        """Update note properties"""
        data = self._request('PATCH', f'/notes/{note_id}', json_data=updates)
        return Note(**data)
    
    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"""
        return self._request('GET', f'/notes/{note_id}/content')
    
    def update_note_content(self, note_id: str, content: str) -> None:
        """Update note content"""
        self._request(
            'PUT',
            f'/notes/{note_id}/content',
            data=content,
            headers={'Content-Type': 'text/plain'}
        )
    
    # 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,
        debug: bool = False
    ) -> List[Note]:
        """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
        if debug:
            params['debug'] = debug
        
        data = self._request('GET', '/notes', params=params)
        return [Note(**note) for note in data.get('results', [])]
    
    # Attributes
    def add_label(
        self,
        note_id: str,
        name: str,
        value: str = "",
        inheritable: bool = False,
        position: Optional[int] = None
    ) -> Attribute:
        """Add a label to a note"""
        data = {
            'noteId': note_id,
            'type': 'label',
            'name': name,
            'value': value,
            'isInheritable': inheritable
        }
        
        if position is not None:
            data['position'] = position
        
        result = self._request('POST', '/attributes', json_data=data)
        return Attribute(**result)
    
    def add_relation(
        self,
        note_id: str,
        name: str,
        target_note_id: str,
        inheritable: bool = False,
        position: Optional[int] = None
    ) -> Attribute:
        """Add a relation to a note"""
        data = {
            'noteId': note_id,
            'type': 'relation',
            'name': name,
            'value': target_note_id,
            'isInheritable': inheritable
        }
        
        if position is not None:
            data['position'] = position
        
        result = self._request('POST', '/attributes', json_data=data)
        return Attribute(**result)
    
    def update_attribute(self, attribute_id: str, **updates) -> Attribute:
        """Update an attribute"""
        result = self._request('PATCH', f'/attributes/{attribute_id}', json_data=updates)
        return Attribute(**result)
    
    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: Optional[str] = None,
        note_position: Optional[int] = None
    ) -> Branch:
        """Clone a note to another location"""
        data = {
            'noteId': note_id,
            'parentNoteId': parent_note_id
        }
        
        if prefix:
            data['prefix'] = prefix
        if note_position is not None:
            data['notePosition'] = note_position
        
        result = self._request('POST', '/branches', json_data=data)
        return Branch(**result)
    
    def update_branch(self, branch_id: str, **updates) -> Branch:
        """Update a branch"""
        result = self._request('PATCH', f'/branches/{branch_id}', json_data=updates)
        return Branch(**result)
    
    def delete_branch(self, branch_id: str) -> None:
        """Delete a branch"""
        self._request('DELETE', f'/branches/{branch_id}')
    
    # Attachments
    def upload_attachment(
        self,
        note_id: str,
        file_path: str,
        title: Optional[str] = None,
        mime: Optional[str] = None,
        position: Optional[int] = None
    ) -> Dict[str, Any]:
        """Upload a file as attachment"""
        import mimetypes
        import os
        
        if title is None:
            title = os.path.basename(file_path)
        
        if mime is None:
            mime = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
        
        with open(file_path, 'rb') as f:
            content = base64.b64encode(f.read()).decode('utf-8')
        
        data = {
            'ownerId': note_id,
            'role': 'file',
            'mime': mime,
            'title': title,
            'content': content
        }
        
        if position is not None:
            data['position'] = position
        
        return self._request('POST', '/attachments', json_data=data)
    
    def download_attachment(self, attachment_id: str, output_path: str) -> str:
        """Download an attachment"""
        content = self._request('GET', f'/attachments/{attachment_id}/content')
        
        with open(output_path, 'wb') as f:
            if isinstance(content, bytes):
                f.write(content)
            else:
                f.write(content.encode('utf-8'))
        
        return output_path
    
    # Special notes
    def get_inbox(self, target_date: Optional[Union[str, date]] = None) -> Note:
        """Get inbox note for a date"""
        if target_date is None:
            target_date = date.today()
        elif isinstance(target_date, date):
            target_date = target_date.strftime('%Y-%m-%d')
        
        data = self._request('GET', f'/inbox/{target_date}')
        return Note(**data)
    
    def get_day_note(self, target_date: Optional[Union[str, date]] = None) -> Note:
        """Get day note for a date"""
        if target_date is None:
            target_date = date.today()
        elif isinstance(target_date, date):
            target_date = target_date.strftime('%Y-%m-%d')
        
        data = self._request('GET', f'/calendar/days/{target_date}')
        return Note(**data)
    
    def get_week_note(self, target_date: Optional[Union[str, date]] = None) -> Note:
        """Get week note for a date"""
        if target_date is None:
            target_date = date.today()
        elif isinstance(target_date, date):
            target_date = target_date.strftime('%Y-%m-%d')
        
        data = self._request('GET', f'/calendar/weeks/{target_date}')
        return Note(**data)
    
    def get_month_note(self, month: Optional[str] = None) -> Note:
        """Get month note"""
        if month is None:
            month = date.today().strftime('%Y-%m')
        
        data = self._request('GET', f'/calendar/months/{month}')
        return Note(**data)
    
    def get_year_note(self, year: Optional[Union[str, int]] = None) -> Note:
        """Get year note"""
        if year is None:
            year = str(date.today().year)
        elif isinstance(year, int):
            year = str(year)
        
        data = self._request('GET', f'/calendar/years/{year}')
        return Note(**data)
    
    # Utility
    def get_app_info(self) -> Dict[str, Any]:
        """Get application information"""
        return self._request('GET', '/app-info')
    
    def create_backup(self, backup_name: str) -> None:
        """Create a backup"""
        self._request('PUT', f'/backup/{backup_name}')
    
    def export_notes(
        self,
        note_id: str,
        output_file: str,
        format: str = 'html'
    ) -> str:
        """Export notes to ZIP file"""
        content = self._request(
            'GET',
            f'/notes/{note_id}/export',
            params={'format': format}
        )
        
        with open(output_file, 'wb') as f:
            f.write(content)
        
        return output_file
    
    def create_note_revision(self, note_id: str) -> None:
        """Create a revision for a note"""
        self._request('POST', f'/notes/{note_id}/revision')
    
    def refresh_note_ordering(self, parent_note_id: str) -> None:
        """Refresh note ordering"""
        self._request('POST', f'/refresh-note-ordering/{parent_note_id}')

# Helper class for batch operations
class TriliumBatchClient(TriliumClient):
    """Extended client with batch operations"""
    
    def create_notes_batch(
        self,
        notes: List[CreateNoteRequest],
        delay: float = 0.1
    ) -> List[Dict[str, Any]]:
        """Create multiple notes with delay between requests"""
        results = []
        
        for note_req in notes:
            result = self.create_note(**asdict(note_req))
            results.append(result)
            time.sleep(delay)
        
        return results
    
    def add_labels_batch(
        self,
        note_id: str,
        labels: Dict[str, str]
    ) -> List[Attribute]:
        """Add multiple labels to a note"""
        results = []
        
        for name, value in labels.items():
            attr = self.add_label(note_id, name, value)
            results.append(attr)
        
        return results
    
    def search_and_tag(
        self,
        search_query: str,
        tag_name: str,
        tag_value: str = ""
    ) -> List[str]:
        """Search for notes and add a tag to all results"""
        notes = self.search_notes(search_query)
        tagged = []
        
        for note in notes:
            self.add_label(note.noteId, tag_name, tag_value)
            tagged.append(note.noteId)
        
        return tagged

# Context manager for automatic connection handling
class TriliumContext:
    """Context manager for Trilium client"""
    
    def __init__(self, base_url: str, token: str, **kwargs):
        self.base_url = base_url
        self.token = token
        self.kwargs = kwargs
        self.client = None
    
    def __enter__(self) -> TriliumClient:
        self.client = TriliumClient(self.base_url, self.token, **self.kwargs)
        return self.client
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.client and hasattr(self.client, 'session'):
            self.client.session.close()

# Usage examples
if __name__ == "__main__":
    # Basic usage
    client = TriliumClient(
        base_url="http://localhost:8080/etapi",
        token="your-token",
        debug=True
    )
    
    # Create a note
    result = client.create_note(
        parent_note_id="root",
        title="Test Note",
        content="<p>This is a test note</p>",
        note_type=NoteType.TEXT
    )
    print(f"Created note: {result['note']['noteId']}")
    
    # Search notes
    todo_notes = client.search_notes("#todo", limit=10)
    for note in todo_notes:
        print(f"- {note.title}")
    
    # Using context manager
    with TriliumContext("http://localhost:8080/etapi", "your-token") as api:
        inbox = api.get_inbox()
        print(f"Inbox note ID: {inbox.noteId}")
    
    # Batch operations
    batch_client = TriliumBatchClient(
        base_url="http://localhost:8080/etapi",
        token="your-token"
    )
    
    # Tag all notes matching a search
    tagged = batch_client.search_and_tag(
        search_query="type:text",
        tag_name="processed",
        tag_value=datetime.now().isoformat()
    )
    print(f"Tagged {len(tagged)} notes")

Go Client

// trilium_client.go

package trilium

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "time"
)

// Note represents a Trilium note
type Note struct {
    NoteID          string      `json:"noteId"`
    Title           string      `json:"title"`
    Type            string      `json:"type"`
    Mime            string      `json:"mime,omitempty"`
    IsProtected     bool        `json:"isProtected"`
    DateCreated     string      `json:"dateCreated,omitempty"`
    DateModified    string      `json:"dateModified,omitempty"`
    UTCDateCreated  string      `json:"utcDateCreated,omitempty"`
    UTCDateModified string      `json:"utcDateModified,omitempty"`
    Attributes      []Attribute `json:"attributes,omitempty"`
    ParentNoteIDs   []string    `json:"parentNoteIds,omitempty"`
    ChildNoteIDs    []string    `json:"childNoteIds,omitempty"`
}

// CreateNoteRequest represents a request to create a note
type CreateNoteRequest struct {
    ParentNoteID string `json:"parentNoteId"`
    Title        string `json:"title"`
    Type         string `json:"type"`
    Content      string `json:"content"`
    NotePosition int    `json:"notePosition,omitempty"`
    Prefix       string `json:"prefix,omitempty"`
    IsExpanded   bool   `json:"isExpanded,omitempty"`
}

// Attribute represents a note attribute
type Attribute struct {
    AttributeID   string `json:"attributeId,omitempty"`
    NoteID        string `json:"noteId"`
    Type          string `json:"type"`
    Name          string `json:"name"`
    Value         string `json:"value"`
    Position      int    `json:"position,omitempty"`
    IsInheritable bool   `json:"isInheritable,omitempty"`
}

// Branch represents a note branch
type Branch struct {
    BranchID     string `json:"branchId,omitempty"`
    NoteID       string `json:"noteId"`
    ParentNoteID string `json:"parentNoteId"`
    Prefix       string `json:"prefix,omitempty"`
    NotePosition int    `json:"notePosition,omitempty"`
    IsExpanded   bool   `json:"isExpanded,omitempty"`
}

// SearchParams represents search parameters
type SearchParams struct {
    Search               string `url:"search"`
    FastSearch           bool   `url:"fastSearch,omitempty"`
    IncludeArchivedNotes bool   `url:"includeArchivedNotes,omitempty"`
    AncestorNoteID       string `url:"ancestorNoteId,omitempty"`
    AncestorDepth        string `url:"ancestorDepth,omitempty"`
    OrderBy              string `url:"orderBy,omitempty"`
    OrderDirection       string `url:"orderDirection,omitempty"`
    Limit                int    `url:"limit,omitempty"`
    Debug                bool   `url:"debug,omitempty"`
}

// SearchResponse represents search results
type SearchResponse struct {
    Results   []Note                 `json:"results"`
    DebugInfo map[string]interface{} `json:"debugInfo,omitempty"`
}

// Client is the Trilium API client
type Client struct {
    BaseURL    string
    Token      string
    HTTPClient *http.Client
}

// NewClient creates a new Trilium client
func NewClient(baseURL, token string) *Client {
    return &Client{
        BaseURL: baseURL,
        Token:   token,
        HTTPClient: &http.Client{
            Timeout: 30 * time.Second,
        },
    }
}

// request makes an HTTP request to the API
func (c *Client) request(method, endpoint string, body interface{}) (*http.Response, error) {
    url := c.BaseURL + endpoint
    
    var reqBody io.Reader
    if body != nil {
        jsonBody, err := json.Marshal(body)
        if err != nil {
            return nil, fmt.Errorf("failed to marshal request body: %w", err)
        }
        reqBody = bytes.NewBuffer(jsonBody)
    }
    
    req, err := http.NewRequest(method, url, reqBody)
    if err != nil {
        return nil, fmt.Errorf("failed to create request: %w", err)
    }
    
    req.Header.Set("Authorization", c.Token)
    if body != nil {
        req.Header.Set("Content-Type", "application/json")
    }
    
    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    
    if resp.StatusCode >= 400 {
        defer resp.Body.Close()
        bodyBytes, _ := io.ReadAll(resp.Body)
        return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes))
    }
    
    return resp, nil
}

// CreateNote creates a new note
func (c *Client) CreateNote(req CreateNoteRequest) (*Note, *Branch, error) {
    resp, err := c.request("POST", "/create-note", req)
    if err != nil {
        return nil, nil, err
    }
    defer resp.Body.Close()
    
    var result struct {
        Note   Note   `json:"note"`
        Branch Branch `json:"branch"`
    }
    
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, nil, fmt.Errorf("failed to decode response: %w", err)
    }
    
    return &result.Note, &result.Branch, nil
}

// GetNote retrieves a note by ID
func (c *Client) GetNote(noteID string) (*Note, error) {
    resp, err := c.request("GET", fmt.Sprintf("/notes/%s", noteID), nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var note Note
    if err := json.NewDecoder(resp.Body).Decode(&note); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }
    
    return &note, nil
}

// UpdateNote updates a note
func (c *Client) UpdateNote(noteID string, updates map[string]interface{}) (*Note, error) {
    resp, err := c.request("PATCH", fmt.Sprintf("/notes/%s", noteID), updates)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var note Note
    if err := json.NewDecoder(resp.Body).Decode(&note); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }
    
    return &note, nil
}

// DeleteNote deletes a note
func (c *Client) DeleteNote(noteID string) error {
    resp, err := c.request("DELETE", fmt.Sprintf("/notes/%s", noteID), nil)
    if err != nil {
        return err
    }
    resp.Body.Close()
    return nil
}

// GetNoteContent retrieves note content
func (c *Client) GetNoteContent(noteID string) (string, error) {
    resp, err := c.request("GET", fmt.Sprintf("/notes/%s/content", noteID), nil)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    
    content, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("failed to read response: %w", err)
    }
    
    return string(content), nil
}

// UpdateNoteContent updates note content
func (c *Client) UpdateNoteContent(noteID, content string) error {
    req, err := http.NewRequest("PUT", c.BaseURL+fmt.Sprintf("/notes/%s/content", noteID), bytes.NewBufferString(content))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    
    req.Header.Set("Authorization", c.Token)
    req.Header.Set("Content-Type", "text/plain")
    
    resp, err := c.HTTPClient.Do(req)
    if err != nil {
        return fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode >= 400 {
        bodyBytes, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("API error %d: %s", resp.StatusCode, string(bodyBytes))
    }
    
    return nil
}

// SearchNotes searches for notes
func (c *Client) SearchNotes(params SearchParams) (*SearchResponse, error) {
    query := url.Values{}
    query.Set("search", params.Search)
    
    if params.FastSearch {
        query.Set("fastSearch", "true")
    }
    if params.IncludeArchivedNotes {
        query.Set("includeArchivedNotes", "true")
    }
    if params.AncestorNoteID != "" {
        query.Set("ancestorNoteId", params.AncestorNoteID)
    }
    if params.OrderBy != "" {
        query.Set("orderBy", params.OrderBy)
    }
    if params.OrderDirection != "" {
        query.Set("orderDirection", params.OrderDirection)
    }
    if params.Limit > 0 {
        query.Set("limit", fmt.Sprintf("%d", params.Limit))
    }
    
    resp, err := c.request("GET", fmt.Sprintf("/notes?%s", query.Encode()), nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var searchResp SearchResponse
    if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }
    
    return &searchResp, nil
}

// AddLabel adds a label to a note
func (c *Client) AddLabel(noteID, name, value string) (*Attribute, error) {
    attr := Attribute{
        NoteID: noteID,
        Type:   "label",
        Name:   name,
        Value:  value,
    }
    
    resp, err := c.request("POST", "/attributes", attr)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var result Attribute
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, fmt.Errorf("failed to decode response: %w", err)
    }
    
    return &result, nil
}

// Usage example
func Example() {
    client := NewClient("http://localhost:8080/etapi", "your-token")
    
    // Create a note
    note, branch, err := client.CreateNote(CreateNoteRequest{
        ParentNoteID: "root",
        Title:        "Test Note",
        Type:         "text",
        Content:      "<p>Hello from Go!</p>",
    })
    
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Created note %s with branch %s\n", note.NoteID, branch.BranchID)
    
    // Search notes
    results, err := client.SearchNotes(SearchParams{
        Search: "#todo",
        Limit:  10,
    })
    
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Found %d todo notes\n", len(results.Results))
}

REST Client Best Practices

1. Connection Management

# Python - Connection pooling with requests
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

class RobustTriliumClient:
    def __init__(self, base_url, token):
        self.base_url = base_url
        self.token = token
        
        # Configure connection pooling and retries
        self.session = requests.Session()
        
        retry_strategy = Retry(
            total=3,
            backoff_factor=1,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE", "POST"]
        )
        
        adapter = HTTPAdapter(
            max_retries=retry_strategy,
            pool_connections=10,
            pool_maxsize=10
        )
        
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)
        
        self.session.headers.update({
            'Authorization': token,
            'Content-Type': 'application/json'
        })

2. Request Timeout Handling

// JavaScript - Timeout with abort controller
class TimeoutClient {
    constructor(baseUrl, token, timeout = 30000) {
        this.baseUrl = baseUrl;
        this.token = token;
        this.timeout = timeout;
    }
    
    async request(endpoint, options = {}) {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), this.timeout);
        
        try {
            const response = await fetch(`${this.baseUrl}${endpoint}`, {
                ...options,
                headers: {
                    'Authorization': this.token,
                    ...options.headers
                },
                signal: controller.signal
            });
            
            clearTimeout(timeoutId);
            
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }
            
            return response.json();
        } catch (error) {
            if (error.name === 'AbortError') {
                throw new Error(`Request timeout after ${this.timeout}ms`);
            }
            throw error;
        } finally {
            clearTimeout(timeoutId);
        }
    }
}

3. Rate Limiting

# Python - Rate limiting with token bucket
import time
from threading import Lock

class RateLimitedClient:
    def __init__(self, base_url, token, requests_per_second=10):
        self.base_url = base_url
        self.token = token
        self.rate_limit = requests_per_second
        self.tokens = requests_per_second
        self.last_update = time.time()
        self.lock = Lock()
    
    def _wait_for_token(self):
        with self.lock:
            now = time.time()
            elapsed = now - self.last_update
            self.tokens = min(
                self.rate_limit,
                self.tokens + elapsed * self.rate_limit
            )
            self.last_update = now
            
            if self.tokens < 1:
                sleep_time = (1 - self.tokens) / self.rate_limit
                time.sleep(sleep_time)
                self.tokens = 1
            
            self.tokens -= 1
    
    def request(self, method, endpoint, **kwargs):
        self._wait_for_token()
        # Make actual request here
        return self._make_request(method, endpoint, **kwargs)

4. Caching

// TypeScript - Response caching
interface CacheEntry<T> {
    data: T;
    timestamp: number;
    ttl: number;
}

class CachedTriliumClient extends TriliumClient {
    private cache = new Map<string, CacheEntry<any>>();
    private defaultTTL = 5 * 60 * 1000; // 5 minutes
    
    private getCacheKey(method: string, endpoint: string, params?: any): string {
        return `${method}:${endpoint}:${JSON.stringify(params || {})}`;
    }
    
    private isExpired(entry: CacheEntry<any>): boolean {
        return Date.now() - entry.timestamp > entry.ttl;
    }
    
    async cachedRequest<T>(
        method: string,
        endpoint: string,
        options: {
            params?: any;
            ttl?: number;
            forceRefresh?: boolean;
        } = {}
    ): Promise<T> {
        const key = this.getCacheKey(method, endpoint, options.params);
        
        // Check cache for GET requests
        if (method === 'GET' && !options.forceRefresh) {
            const cached = this.cache.get(key);
            if (cached && !this.isExpired(cached)) {
                return cached.data;
            }
        }
        
        // Make request
        const data = await this.request(endpoint, {
            method,
            params: options.params
        });
        
        // Cache GET responses
        if (method === 'GET') {
            this.cache.set(key, {
                data,
                timestamp: Date.now(),
                ttl: options.ttl || this.defaultTTL
            });
        }
        
        return data;
    }
    
    clearCache(pattern?: string): void {
        if (pattern) {
            for (const key of this.cache.keys()) {
                if (key.includes(pattern)) {
                    this.cache.delete(key);
                }
            }
        } else {
            this.cache.clear();
        }
    }
}

Error Handling Patterns

Comprehensive Error Handling

# Python - Detailed error handling
class TriliumAPIError(Exception):
    """Base exception for API errors"""
    def __init__(self, message, status_code=None, response_data=None):
        super().__init__(message)
        self.status_code = status_code
        self.response_data = response_data

class TriliumValidationError(TriliumAPIError):
    """Validation error (400)"""
    pass

class TriliumAuthenticationError(TriliumAPIError):
    """Authentication error (401)"""
    pass

class TriliumPermissionError(TriliumAPIError):
    """Permission error (403)"""
    pass

class TriliumNotFoundError(TriliumAPIError):
    """Resource not found (404)"""
    pass

class TriliumRateLimitError(TriliumAPIError):
    """Rate limit exceeded (429)"""
    pass

class TriliumServerError(TriliumAPIError):
    """Server error (5xx)"""
    pass

def handle_api_error(response):
    """Handle API error responses"""
    try:
        error_data = response.json()
        message = error_data.get('message', response.reason)
    except:
        message = response.reason
        error_data = None
    
    status_code = response.status_code
    
    if status_code == 400:
        raise TriliumValidationError(message, status_code, error_data)
    elif status_code == 401:
        raise TriliumAuthenticationError(message, status_code, error_data)
    elif status_code == 403:
        raise TriliumPermissionError(message, status_code, error_data)
    elif status_code == 404:
        raise TriliumNotFoundError(message, status_code, error_data)
    elif status_code == 429:
        raise TriliumRateLimitError(message, status_code, error_data)
    elif status_code >= 500:
        raise TriliumServerError(message, status_code, error_data)
    else:
        raise TriliumAPIError(message, status_code, error_data)

# Usage
try:
    note = client.get_note('invalid_id')
except TriliumNotFoundError as e:
    print(f"Note not found: {e}")
except TriliumAuthenticationError as e:
    print(f"Authentication failed: {e}")
    # Refresh token or re-authenticate
except TriliumServerError as e:
    print(f"Server error: {e}")
    # Retry after delay
except TriliumAPIError as e:
    print(f"API error ({e.status_code}): {e}")

Retry Strategies

Exponential Backoff

// JavaScript - Exponential backoff with jitter
class RetryClient {
    constructor(baseUrl, token, maxRetries = 3) {
        this.baseUrl = baseUrl;
        this.token = token;
        this.maxRetries = maxRetries;
    }
    
    async requestWithRetry(endpoint, options = {}, attempt = 0) {
        try {
            const response = await fetch(`${this.baseUrl}${endpoint}`, {
                ...options,
                headers: {
                    'Authorization': this.token,
                    ...options.headers
                }
            });
            
            if (response.status >= 500 && attempt < this.maxRetries) {
                throw new Error(`Server error: ${response.status}`);
            }
            
            if (!response.ok) {
                const error = await response.json();
                throw new Error(error.message || `HTTP ${response.status}`);
            }
            
            return response.json();
            
        } catch (error) {
            if (attempt >= this.maxRetries) {
                throw error;
            }
            
            // Calculate delay with exponential backoff and jitter
            const baseDelay = Math.pow(2, attempt) * 1000;
            const jitter = Math.random() * 1000;
            const delay = baseDelay + jitter;
            
            console.log(`Retry attempt ${attempt + 1} after ${delay}ms`);
            
            await new Promise(resolve => setTimeout(resolve, delay));
            
            return this.requestWithRetry(endpoint, options, attempt + 1);
        }
    }
}

Circuit Breaker Pattern

# Python - Circuit breaker implementation
import time
from enum import Enum
from threading import Lock

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class CircuitBreaker:
    def __init__(
        self,
        failure_threshold=5,
        recovery_timeout=60,
        expected_exception=Exception
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.expected_exception = expected_exception
        
        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED
        self.lock = Lock()
    
    def call(self, func, *args, **kwargs):
        with self.lock:
            if self.state == CircuitState.OPEN:
                if time.time() - self.last_failure_time > self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                else:
                    raise Exception("Circuit breaker is OPEN")
        
        try:
            result = func(*args, **kwargs)
            with self.lock:
                self.on_success()
            return result
        except self.expected_exception as e:
            with self.lock:
                self.on_failure()
            raise e
    
    def on_success(self):
        self.failure_count = 0
        self.state = CircuitState.CLOSED
    
    def on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        
        if self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN

class CircuitBreakerClient(TriliumClient):
    def __init__(self, base_url, token):
        super().__init__(base_url, token)
        self.circuit_breaker = CircuitBreaker(
            failure_threshold=5,
            recovery_timeout=60,
            expected_exception=TriliumConnectionError
        )
    
    def _request(self, method, endpoint, **kwargs):
        return self.circuit_breaker.call(
            super()._request,
            method,
            endpoint,
            **kwargs
        )

Testing Client Libraries

Unit Testing

# Python - Unit tests with mocking
import unittest
from unittest.mock import Mock, patch, MagicMock
import json

class TestTriliumClient(unittest.TestCase):
    def setUp(self):
        self.client = TriliumClient(
            base_url="http://localhost:8080/etapi",
            token="test-token"
        )
    
    @patch('requests.Session.request')
    def test_create_note(self, mock_request):
        # Mock response
        mock_response = Mock()
        mock_response.status_code = 201
        mock_response.json.return_value = {
            'note': {
                'noteId': 'test123',
                'title': 'Test Note',
                'type': 'text'
            },
            'branch': {
                'branchId': 'branch123',
                'noteId': 'test123',
                'parentNoteId': 'root'
            }
        }
        mock_request.return_value = mock_response
        
        # Test create note
        result = self.client.create_note(
            parent_note_id='root',
            title='Test Note',
            content='<p>Test content</p>'
        )
        
        # Assertions
        self.assertEqual(result['note']['noteId'], 'test123')
        self.assertEqual(result['note']['title'], 'Test Note')
        
        # Verify request was made correctly
        mock_request.assert_called_once()
        call_args = mock_request.call_args
        self.assertEqual(call_args[1]['method'], 'POST')
        self.assertEqual(call_args[1]['url'], 'http://localhost:8080/etapi/create-note')
    
    @patch('requests.Session.request')
    def test_search_notes(self, mock_request):
        # Mock response
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            'results': [
                {'noteId': 'note1', 'title': 'Note 1'},
                {'noteId': 'note2', 'title': 'Note 2'}
            ]
        }
        mock_request.return_value = mock_response
        
        # Test search
        results = self.client.search_notes('#todo', limit=10)
        
        # Assertions
        self.assertEqual(len(results), 2)
        self.assertEqual(results[0].noteId, 'note1')
        
    @patch('requests.Session.request')
    def test_error_handling(self, mock_request):
        # Mock error response
        mock_response = Mock()
        mock_response.status_code = 404
        mock_response.json.return_value = {
            'status': 404,
            'code': 'NOTE_NOT_FOUND',
            'message': 'Note not found'
        }
        mock_request.return_value = mock_response
        
        # Test error handling
        with self.assertRaises(TriliumNotFoundError) as context:
            self.client.get_note('invalid_id')
        
        self.assertEqual(context.exception.status_code, 404)
        self.assertIn('Note not found', str(context.exception))

class TestRetryLogic(unittest.TestCase):
    @patch('time.sleep')
    @patch('requests.Session.request')
    def test_retry_on_server_error(self, mock_request, mock_sleep):
        client = TriliumClient(
            base_url="http://localhost:8080/etapi",
            token="test-token",
            retry_attempts=3
        )
        
        # Mock server error then success
        mock_response_error = Mock()
        mock_response_error.status_code = 500
        
        mock_response_success = Mock()
        mock_response_success.status_code = 200
        mock_response_success.json.return_value = {'noteId': 'test123'}
        
        mock_request.side_effect = [
            mock_response_error,
            mock_response_error,
            mock_response_success
        ]
        
        # Should succeed after retries
        result = client.get_note('test123')
        self.assertEqual(result.noteId, 'test123')
        
        # Verify retries happened
        self.assertEqual(mock_request.call_count, 3)
        self.assertEqual(mock_sleep.call_count, 2)

if __name__ == '__main__':
    unittest.main()

Integration Testing

// JavaScript - Integration tests with Jest
describe('TriliumClient Integration Tests', () => {
    let client;
    let testNoteId;
    
    beforeAll(() => {
        client = new TriliumClient({
            baseUrl: process.env.TRILIUM_URL || 'http://localhost:8080/etapi',
            token: process.env.TRILIUM_TOKEN || 'test-token'
        });
    });
    
    afterAll(async () => {
        // Cleanup test notes
        if (testNoteId) {
            await client.deleteNote(testNoteId);
        }
    });
    
    test('should create and retrieve a note', async () => {
        // Create note
        const createResult = await client.createNote({
            parentNoteId: 'root',
            title: 'Integration Test Note',
            type: 'text',
            content: '<p>Test content</p>'
        });
        
        expect(createResult.note).toBeDefined();
        expect(createResult.note.title).toBe('Integration Test Note');
        
        testNoteId = createResult.note.noteId;
        
        // Retrieve note
        const note = await client.getNote(testNoteId);
        expect(note.noteId).toBe(testNoteId);
        expect(note.title).toBe('Integration Test Note');
    });
    
    test('should update note content', async () => {
        const newContent = '<p>Updated content</p>';
        
        await client.updateNoteContent(testNoteId, newContent);
        
        const content = await client.getNoteContent(testNoteId);
        expect(content).toBe(newContent);
    });
    
    test('should add and retrieve attributes', async () => {
        // Add label
        const attribute = await client.createAttribute({
            noteId: testNoteId,
            type: 'label',
            name: 'testLabel',
            value: 'testValue'
        });
        
        expect(attribute.attributeId).toBeDefined();
        
        // Retrieve note with attributes
        const note = await client.getNote(testNoteId);
        const label = note.attributes.find(a => a.name === 'testLabel');
        
        expect(label).toBeDefined();
        expect(label.value).toBe('testValue');
    });
    
    test('should search notes', async () => {
        // Add searchable label
        await client.createAttribute({
            noteId: testNoteId,
            type: 'label',
            name: 'integrationTest',
            value: ''
        });
        
        // Search
        const results = await client.searchNotes({
            search: '#integrationTest',
            limit: 10
        });
        
        expect(results.results).toBeDefined();
        expect(results.results.length).toBeGreaterThan(0);
        
        const foundNote = results.results.find(n => n.noteId === testNoteId);
        expect(foundNote).toBeDefined();
    });
});

Conclusion

These client libraries provide robust, production-ready implementations for interacting with the Trilium API. Key considerations:

  1. Choose the right language for your use case and environment
  2. Implement proper error handling with specific exception types
  3. Use connection pooling for better performance
  4. Add retry logic for resilience against transient failures
  5. Consider rate limiting to avoid overwhelming the server
  6. Cache responses when appropriate to reduce API calls
  7. Write comprehensive tests for reliability
  8. Document your client with clear examples

For additional resources: