mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-30 18:05:55 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			2275 lines
		
	
	
		
			67 KiB
		
	
	
	
		
			Markdown
		
	
	
	
		
			Vendored
		
	
	
	
			
		
		
	
	
			2275 lines
		
	
	
		
			67 KiB
		
	
	
	
		
			Markdown
		
	
	
	
		
			Vendored
		
	
	
	
| # API Client Libraries
 | |
| 
 | |
| ## Table of Contents
 | |
| 1. [Overview](#overview)
 | |
| 2. [JavaScript/TypeScript Client](#javascripttypescript-client)
 | |
| 3. [Python Client - trilium-py](#python-client---trilium-py)
 | |
| 4. [Go Client](#go-client)
 | |
| 5. [Ruby Client](#ruby-client)
 | |
| 6. [PHP Client](#php-client)
 | |
| 7. [C# Client](#c-client)
 | |
| 8. [Rust Client](#rust-client)
 | |
| 9. [REST Client Best Practices](#rest-client-best-practices)
 | |
| 10. [Error Handling Patterns](#error-handling-patterns)
 | |
| 11. [Retry Strategies](#retry-strategies)
 | |
| 12. [Testing Client Libraries](#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
 | |
| 
 | |
| ### Full-Featured TypeScript Implementation
 | |
| 
 | |
| ```typescript
 | |
| // 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
 | |
| 
 | |
| ```javascript
 | |
| // 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
 | |
| 
 | |
| ```bash
 | |
| pip install trilium-py
 | |
| ```
 | |
| 
 | |
| ### Complete Python Implementation
 | |
| 
 | |
| ```python
 | |
| # 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
 | |
| 
 | |
| ```go
 | |
| // 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(¬e); err != nil {
 | |
|         return nil, fmt.Errorf("failed to decode response: %w", err)
 | |
|     }
 | |
|     
 | |
|     return ¬e, 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(¬e); err != nil {
 | |
|         return nil, fmt.Errorf("failed to decode response: %w", err)
 | |
|     }
 | |
|     
 | |
|     return ¬e, 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
 | |
| # 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
 | |
| // 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
 | |
| # 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
 | |
| // 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
 | |
| # 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
 | |
| // 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
 | |
| # 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
 | |
| # 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
 | |
| // 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:
 | |
| - [ETAPI Complete Guide](./ETAPI%20Complete%20Guide.md)
 | |
| - [WebSocket API Documentation](./WebSocket%20API.md)
 | |
| - [Script API Cookbook](./Script%20API%20Cookbook.md) |