diff --git a/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md b/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md new file mode 100644 index 00000000000..b331da09914 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/API Client Libraries.md @@ -0,0 +1,2277 @@ +# 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; + + 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 { + 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 { + const response = await this.client.get(`/notes/${noteId}`); + return response.data; + } + + async updateNote(noteId: string, updates: Partial): Promise { + const response = await this.client.patch(`/notes/${noteId}`, updates); + return response.data; + } + + async deleteNote(noteId: string): Promise { + await this.client.delete(`/notes/${noteId}`); + } + + async getNoteContent(noteId: string): Promise { + const response = await this.client.get(`/notes/${noteId}/content`, { + responseType: 'text' + }); + return response.data; + } + + async updateNoteContent(noteId: string, content: string): Promise { + await this.client.put(`/notes/${noteId}/content`, content, { + headers: { 'Content-Type': 'text/plain' } + }); + } + + // Search + async searchNotes(params: SearchParams): Promise { + const response = await this.client.get('/notes', { params }); + return response.data; + } + + // Attributes + async createAttribute(attribute: Omit): Promise { + const response = await this.client.post('/attributes', attribute); + return response.data; + } + + async updateAttribute(attributeId: string, updates: Partial): Promise { + const response = await this.client.patch(`/attributes/${attributeId}`, updates); + return response.data; + } + + async deleteAttribute(attributeId: string): Promise { + await this.client.delete(`/attributes/${attributeId}`); + } + + // Branches + async createBranch(branch: Omit): Promise { + const response = await this.client.post('/branches', branch); + return response.data; + } + + async updateBranch(branchId: string, updates: Partial): Promise { + const response = await this.client.patch(`/branches/${branchId}`, updates); + return response.data; + } + + async deleteBranch(branchId: string): Promise { + await this.client.delete(`/branches/${branchId}`); + } + + // Attachments + async createAttachment(attachment: { + ownerId: string; + role: string; + mime: string; + title: string; + content: string; + position?: number; + }): Promise { + const response = await this.client.post('/attachments', attachment); + return response.data; + } + + async getAttachment(attachmentId: string): Promise { + const response = await this.client.get(`/attachments/${attachmentId}`); + return response.data; + } + + async getAttachmentContent(attachmentId: string): Promise { + const response = await this.client.get(`/attachments/${attachmentId}/content`, { + responseType: 'arraybuffer' + }); + return response.data; + } + + async deleteAttachment(attachmentId: string): Promise { + await this.client.delete(`/attachments/${attachmentId}`); + } + + // Special notes + async getInboxNote(date: string): Promise { + const response = await this.client.get(`/inbox/${date}`); + return response.data; + } + + async getDayNote(date: string): Promise { + const response = await this.client.get(`/calendar/days/${date}`); + return response.data; + } + + async getWeekNote(date: string): Promise { + const response = await this.client.get(`/calendar/weeks/${date}`); + return response.data; + } + + async getMonthNote(month: string): Promise { + const response = await this.client.get(`/calendar/months/${month}`); + return response.data; + } + + async getYearNote(year: string): Promise { + const response = await this.client.get(`/calendar/years/${year}`); + return response.data; + } + + // Utility + async getAppInfo(): Promise { + const response = await this.client.get('/app-info'); + return response.data; + } + + async createBackup(backupName: string): Promise { + await this.client.put(`/backup/${backupName}`); + } + + async exportNotes(noteId: string, format: 'html' | 'markdown' = 'html'): Promise { + 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): TriliumClient { + return new TriliumClient({ + baseUrl, + token, + ...options + }); +} + +// Batch operations helper +export class TriliumBatchClient extends TriliumClient { + async createMultipleNotes(notes: CreateNoteParams[]): Promise> { + 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 | null + ): Promise { + 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: '

Hello, Trilium!

' + }); + + 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 + +```sh +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="

This is a test note

", + 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: "

Hello from Go!

", + }) + + 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 { + data: T; + timestamp: number; + ttl: number; +} + +class CachedTriliumClient extends TriliumClient { + private cache = new Map>(); + 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): boolean { + return Date.now() - entry.timestamp > entry.ttl; + } + + async cachedRequest( + method: string, + endpoint: string, + options: { + params?: any; + ttl?: number; + forceRefresh?: boolean; + } = {} + ): Promise { + 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='

Test content

' + ) + + # 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: '

Test content

' + }); + + 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 = '

Updated content

'; + + 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) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md b/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md new file mode 100644 index 00000000000..8e9d694a700 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/ETAPI Complete Guide.md @@ -0,0 +1,1798 @@ +# ETAPI Complete Guide +## ETAPI Guide + +ETAPI (External Trilium API) is the REST API for integrating external applications with Trilium Notes. This guide walks you through authentication, common operations, and best practices for building integrations. + +## Getting Started + +ETAPI provides programmatic access to your notes, attributes, and attachments through a RESTful interface. The API is designed to be predictable and easy to use, with comprehensive error messages to help you debug issues quickly. + +Base URL: `http://localhost:8080/etapi` + +## Authentication + +Before using the API, you need to authenticate your requests. There are three ways to authenticate with ETAPI. + +### Token Authentication (Recommended) + +First, generate an ETAPI token in Trilium by navigating to Options → ETAPI and clicking "Create new ETAPI token". Then include this token in your request headers: + +```sh +curl -H "Authorization: your-token" http://localhost:8080/etapi/notes/root +``` + +In Python: + +```python +import requests + +headers = {'Authorization': 'your-token'} +response = requests.get('http://localhost:8080/etapi/notes/root', headers=headers) +``` + +### Basic Authentication + +You can also use the token as a password with any username: + +```sh +curl -u "user:your-token" http://localhost:8080/etapi/notes/root +``` + +### Password Authentication + +For automated scripts, you can login with your Trilium password to receive a session token. This method is useful when you cannot store API tokens securely: + +```python +# Login and get session token +response = requests.post('http://localhost:8080/etapi/auth/login', + json={'password': 'your-password'}) +token = response.json()['authToken'] + +# Use the session token +headers = {'Authorization': token} +``` + +## API Endpoints + +### Notes + +#### Create Note + +**POST** `/etapi/create-note` + +Creating a note requires just a parent note ID and title. The content and other properties are optional: + +```json +{ + "parentNoteId": "root", + "title": "My New Note", + "type": "text", + "content": "

Note content in HTML

" +} +``` + +The response includes both the created note and its branch (position in the tree): + +```python +def create_note(parent_id, title, content=""): + response = requests.post( + "http://localhost:8080/etapi/create-note", + headers={'Authorization': 'your-token'}, + json={ + "parentNoteId": parent_id, + "title": title, + "content": content + } + ) + return response.json() + +# Create a simple note +note = create_note("root", "Meeting Notes") +print(f"Created: {note['note']['noteId']}") +``` + +#### Get Note by ID + +**GET** `/etapi/notes/{noteId}` + +**cURL Example:** + +```sh +curl -X GET http://localhost:8080/etapi/notes/evnnmvHTCgIn \ + -H "Authorization: your-token" +``` + +**Response:** + +```json +{ + "noteId": "evnnmvHTCgIn", + "title": "My Note", + "type": "text", + "mime": "text/html", + "isProtected": false, + "attributes": [ + { + "attributeId": "abc123", + "noteId": "evnnmvHTCgIn", + "type": "label", + "name": "todo", + "value": "", + "position": 10, + "isInheritable": false + } + ], + "parentNoteIds": ["root"], + "childNoteIds": ["child1", "child2"], + "dateCreated": "2024-01-15 10:30:00.000+0100", + "dateModified": "2024-01-15 14:20:00.000+0100", + "utcDateCreated": "2024-01-15 09:30:00.000Z", + "utcDateModified": "2024-01-15 13:20:00.000Z" +} +``` + +#### Update Note + +**PATCH** `/etapi/notes/{noteId}` + +**Request Body:** + +```json +{ + "title": "Updated Title", + "type": "text", + "mime": "text/html" +} +``` + +**JavaScript Example:** + +```javascript +async function updateNote(noteId, updates) { + const response = await fetch(`http://localhost:8080/etapi/notes/${noteId}`, { + method: 'PATCH', + headers: { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + throw new Error(`Failed to update note: ${response.statusText}`); + } + + return response.json(); +} + +// Usage +updateNote('evnnmvHTCgIn', { title: 'New Title' }) + .then(note => console.log('Updated note:', note)) + .catch(err => console.error('Error:', err)); +``` + +#### Delete Note + +**DELETE** `/etapi/notes/{noteId}` + +```sh +curl -X DELETE http://localhost:8080/etapi/notes/evnnmvHTCgIn \ + -H "Authorization: your-token" +``` + +#### Get Note Content + +**GET** `/etapi/notes/{noteId}/content` + +Returns the content of a note. + +```python +import requests + +def get_note_content(note_id, token): + url = f"http://localhost:8080/etapi/notes/{note_id}/content" + headers = {'Authorization': token} + + response = requests.get(url, headers=headers) + return response.text # Returns HTML or text content + +content = get_note_content('evnnmvHTCgIn', 'your-token') +print(content) +``` + +#### Update Note Content + +**PUT** `/etapi/notes/{noteId}/content` + +```python +def update_note_content(note_id, content, token): + url = f"http://localhost:8080/etapi/notes/{note_id}/content" + headers = { + 'Authorization': token, + 'Content-Type': 'text/plain' + } + + response = requests.put(url, headers=headers, data=content) + return response.status_code == 204 + +# Update with HTML content +html_content = "

Updated Content

New paragraph

" +success = update_note_content('evnnmvHTCgIn', html_content, 'your-token') +``` + +### Search + +#### Search Notes + +**GET** `/etapi/notes` + +Search notes using Trilium's powerful search syntax. The search parameter accepts keywords, labels, and complex expressions. + +```python +# Simple keyword search +results = requests.get( + "http://localhost:8080/etapi/notes", + headers={'Authorization': 'token'}, + params={'search': 'project management'} +).json() + +# Search by label +results = requests.get( + "http://localhost:8080/etapi/notes", + params={'search': '#todo'} +).json() + +# Complex search with sorting +results = requests.get( + "http://localhost:8080/etapi/notes", + params={ + 'search': 'type:text #important', + 'orderBy': 'dateModified', + 'orderDirection': 'desc', + 'limit': 10 + } +).json() +``` + +Common search patterns: + +* Keywords: `project management` +* Labels: `#todo`, `#priority=high` +* Note type: `type:text`, `type:code` +* Date ranges: `dateCreated>=2024-01-01` +* Subtree search: Use `ancestorNoteId` parameter + +### Attributes + +Attributes are key-value metadata attached to notes. There are two types: labels (name-value pairs) and relations (links to other notes). + +#### Create Attribute + +**POST** `/etapi/attributes` + +```python +# Add a simple label +requests.post( + "http://localhost:8080/etapi/attributes", + headers={'Authorization': 'token'}, + json={ + "noteId": "note123", + "type": "label", + "name": "todo" + } +) + +# Add label with value +requests.post( + "http://localhost:8080/etapi/attributes", + json={ + "noteId": "note123", + "type": "label", + "name": "priority", + "value": "high" + } +) +``` + +#### Update Attribute + +**PATCH** `/etapi/attributes/{attributeId}` + +You can only update the value and position of labels. Relations can only have their position updated. + +```python +# Update attribute value +requests.patch( + "http://localhost:8080/etapi/attributes/attr123", + json={"value": "low"} +) +``` + +#### Delete Attribute + +**DELETE** `/etapi/attributes/{attributeId}` + +### Branches + +Branches represent the position of notes in the tree structure. A note can appear in multiple locations through cloning. + +#### Clone Note to Another Location + +**POST** `/etapi/branches` + +```python +# Clone a note to a new parent +requests.post( + "http://localhost:8080/etapi/branches", + headers={'Authorization': 'token'}, + json={ + "noteId": "note123", + "parentNoteId": "newParent456" + } +) +``` + +This creates a "clone" - the same note appearing in multiple places. Changes to the note content affect all locations. + +#### Move Note Position + +**PATCH** `/etapi/branches/{branchId}` + +```python +# Change note position or prefix +requests.patch( + "http://localhost:8080/etapi/branches/branch123", + json={"notePosition": 5} +) +``` + +#### Remove Branch + +**DELETE** `/etapi/branches/{branchId}` + +Removes a note from one location. If it's the last location, the note itself is deleted. + +### Attachments + +#### Create Attachment + +**POST** `/etapi/attachments` + +```json +{ + "ownerId": "evnnmvHTCgIn", + "role": "file", + "mime": "application/pdf", + "title": "document.pdf", + "content": "base64-encoded-content", + "position": 10 +} +``` + +**Python Example with File Upload:** + +```python +import base64 + +def upload_attachment(note_id, file_path, title=None): + with open(file_path, 'rb') as f: + content = base64.b64encode(f.read()).decode('utf-8') + + import mimetypes + mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' + + if title is None: + import os + title = os.path.basename(file_path) + + url = "http://localhost:8080/etapi/attachments" + headers = { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + } + + data = { + "ownerId": note_id, + "role": "file", + "mime": mime_type, + "title": title, + "content": content + } + + response = requests.post(url, headers=headers, json=data) + return response.json() + +# Upload a PDF +attachment = upload_attachment("evnnmvHTCgIn", "/path/to/document.pdf") +print(f"Attachment ID: {attachment['attachmentId']}") +``` + +#### Get Attachment Content + +**GET** `/etapi/attachments/{attachmentId}/content` + +```python +def download_attachment(attachment_id, output_path): + url = f"http://localhost:8080/etapi/attachments/{attachment_id}/content" + headers = {'Authorization': 'your-token'} + + response = requests.get(url, headers=headers) + + with open(output_path, 'wb') as f: + f.write(response.content) + + return output_path + +# Download attachment +download_attachment("attachId123", "/tmp/downloaded.pdf") +``` + +### Special Notes + +#### Get Inbox Note + +**GET** `/etapi/inbox/{date}` + +Gets or creates an inbox note for the specified date. + +```python +from datetime import date + +def get_inbox_note(target_date=None): + if target_date is None: + target_date = date.today() + + date_str = target_date.strftime('%Y-%m-%d') + url = f"http://localhost:8080/etapi/inbox/{date_str}" + headers = {'Authorization': 'your-token'} + + response = requests.get(url, headers=headers) + return response.json() + +# Get today's inbox +inbox = get_inbox_note() +print(f"Inbox note ID: {inbox['noteId']}") +``` + +#### Calendar Notes + +**Day Note:** + +```python +def get_day_note(date_str): + url = f"http://localhost:8080/etapi/calendar/days/{date_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +day_note = get_day_note("2024-01-15") +``` + +**Week Note:** + +```python +def get_week_note(date_str): + url = f"http://localhost:8080/etapi/calendar/weeks/{date_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +week_note = get_week_note("2024-01-15") +``` + +**Month Note:** + +```python +def get_month_note(month_str): + url = f"http://localhost:8080/etapi/calendar/months/{month_str}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +month_note = get_month_note("2024-01") +``` + +**Year Note:** + +```python +def get_year_note(year): + url = f"http://localhost:8080/etapi/calendar/years/{year}" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +year_note = get_year_note("2024") +``` + +### Import/Export + +#### Export Note Subtree + +**GET** `/etapi/notes/{noteId}/export` + +Exports a note subtree as a ZIP file. + +**Query Parameters:** + +* `format`: "html" (default) or "markdown" + +```python +def export_subtree(note_id, output_file, format="html"): + url = f"http://localhost:8080/etapi/notes/{note_id}/export" + headers = {'Authorization': 'your-token'} + params = {'format': format} + + response = requests.get(url, headers=headers, params=params) + + with open(output_file, 'wb') as f: + f.write(response.content) + + return output_file + +# Export entire database +export_subtree("root", "backup.zip") + +# Export specific subtree as markdown +export_subtree("projectNoteId", "project.zip", format="markdown") +``` + +#### Import ZIP + +**POST** `/etapi/notes/{noteId}/import` + +Imports a ZIP file into a note. + +```python +def import_zip(parent_note_id, zip_file_path): + url = f"http://localhost:8080/etapi/notes/{parent_note_id}/import" + headers = {'Authorization': 'your-token'} + + with open(zip_file_path, 'rb') as f: + files = {'file': f} + response = requests.post(url, headers=headers, files=files) + + return response.json() + +# Import backup +imported = import_zip("root", "backup.zip") +print(f"Imported note ID: {imported['note']['noteId']}") +``` + +### Utility Endpoints + +#### Create Note Revision + +**POST** `/etapi/notes/{noteId}/revision` + +Forces creation of a revision for the specified note. + +```sh +curl -X POST http://localhost:8080/etapi/notes/evnnmvHTCgIn/revision \ + -H "Authorization: your-token" +``` + +#### Refresh Note Ordering + +**POST** `/etapi/refresh-note-ordering/{parentNoteId}` + +Updates note ordering in connected clients after changing positions. + +```python +def reorder_children(parent_id, note_positions): + """ + note_positions: dict of {noteId: position} + """ + headers = { + 'Authorization': 'your-token', + 'Content-Type': 'application/json' + } + + # Update each branch position + for note_id, position in note_positions.items(): + # Get the branch ID first + note = requests.get( + f"http://localhost:8080/etapi/notes/{note_id}", + headers=headers + ).json() + + for branch_id in note['parentBranchIds']: + branch = requests.get( + f"http://localhost:8080/etapi/branches/{branch_id}", + headers=headers + ).json() + + if branch['parentNoteId'] == parent_id: + # Update position + requests.patch( + f"http://localhost:8080/etapi/branches/{branch_id}", + headers=headers, + json={'notePosition': position} + ) + + # Refresh ordering + requests.post( + f"http://localhost:8080/etapi/refresh-note-ordering/{parent_id}", + headers=headers + ) + +# Reorder notes +reorder_children("parentId", { + "note1": 10, + "note2": 20, + "note3": 30 +}) +``` + +#### Get App Info + +**GET** `/etapi/app-info` + +Returns information about the Trilium instance. + +```python +def get_app_info(): + url = "http://localhost:8080/etapi/app-info" + headers = {'Authorization': 'your-token'} + response = requests.get(url, headers=headers) + return response.json() + +info = get_app_info() +print(f"Trilium version: {info['appVersion']}") +print(f"Database version: {info['dbVersion']}") +print(f"Data directory: {info['dataDirectory']}") +``` + +#### Create Backup + +**PUT** `/etapi/backup/{backupName}` + +Creates a database backup. + +```sh +curl -X PUT http://localhost:8080/etapi/backup/daily \ + -H "Authorization: your-token" +``` + +This creates a backup file named `backup-daily.db` in the data directory. + +## Common Use Cases + +### 1\. Daily Journal Entry + +```python +from datetime import date +import requests + +class TriliumJournal: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + + def create_journal_entry(self, content, tags=[]): + # Get today's day note + today = date.today().strftime('%Y-%m-%d') + day_note_url = f"{self.base_url}/calendar/days/{today}" + day_note = requests.get(day_note_url, headers=self.headers).json() + + # Create entry + entry_data = { + "parentNoteId": day_note['noteId'], + "title": f"Entry - {date.today().strftime('%H:%M')}", + "type": "text", + "content": content + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=entry_data + ) + + entry = response.json() + + # Add tags + for tag in tags: + self.add_tag(entry['note']['noteId'], tag) + + return entry + + def add_tag(self, note_id, tag_name): + attr_data = { + "noteId": note_id, + "type": "label", + "name": tag_name, + "value": "" + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=attr_data + ) + +# Usage +journal = TriliumJournal("http://localhost:8080/etapi", "your-token") +entry = journal.create_journal_entry( + "

Today's meeting went well. Key decisions:

  • Item 1
", + tags=["meeting", "important"] +) +``` + +### 2\. Task Management System + +```python +class TriliumTaskManager: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + self.task_parent_id = self.get_or_create_task_root() + + def get_or_create_task_root(self): + # Search for existing task root + search_url = f"{self.base_url}/notes" + params = {'search': '#taskRoot'} + response = requests.get(search_url, headers=self.headers, params=params) + results = response.json()['results'] + + if results: + return results[0]['noteId'] + + # Create task root + data = { + "parentNoteId": "root", + "title": "Tasks", + "type": "text", + "content": "

Task Management System

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + note_id = response.json()['note']['noteId'] + + # Add taskRoot label + self.add_label(note_id, "taskRoot") + return note_id + + def create_task(self, title, description, priority="medium", due_date=None): + data = { + "parentNoteId": self.task_parent_id, + "title": title, + "type": "text", + "content": f"

{description}

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + task = response.json() + task_id = task['note']['noteId'] + + # Add task attributes + self.add_label(task_id, "task") + self.add_label(task_id, "todoStatus", "todo") + self.add_label(task_id, "priority", priority) + + if due_date: + self.add_label(task_id, "dueDate", due_date) + + return task + + def get_tasks(self, status=None): + if status: + search = f"#task #todoStatus={status}" + else: + search = "#task" + + params = { + 'search': search, + 'ancestorNoteId': self.task_parent_id + } + + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + return response.json()['results'] + + def complete_task(self, task_id): + # Find the todoStatus attribute + note = requests.get( + f"{self.base_url}/notes/{task_id}", + headers=self.headers + ).json() + + for attr in note['attributes']: + if attr['name'] == 'todoStatus': + # Update status + requests.patch( + f"{self.base_url}/attributes/{attr['attributeId']}", + headers={**self.headers, 'Content-Type': 'application/json'}, + json={'value': 'done'} + ) + break + + def add_label(self, note_id, name, value=""): + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + +# Usage +tasks = TriliumTaskManager("http://localhost:8080/etapi", "your-token") + +# Create tasks +task1 = tasks.create_task( + "Review API documentation", + "Check for completeness and accuracy", + priority="high", + due_date="2024-01-20" +) + +task2 = tasks.create_task( + "Update client library", + "Add new ETAPI endpoints", + priority="medium" +) + +# List pending tasks +pending = tasks.get_tasks(status="todo") +for task in pending: + print(f"- {task['title']}") + +# Complete a task +tasks.complete_task(task1['note']['noteId']) +``` + +### 3\. Knowledge Base Builder + +```python +class KnowledgeBase: + def __init__(self, base_url, token): + self.base_url = base_url + self.headers = {'Authorization': token} + + def create_article(self, category, title, content, tags=[]): + # Find or create category + category_id = self.get_or_create_category(category) + + # Create article + data = { + "parentNoteId": category_id, + "title": title, + "type": "text", + "content": content + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + article = response.json() + article_id = article['note']['noteId'] + + # Add tags + for tag in tags: + self.add_label(article_id, tag) + + # Add article label + self.add_label(article_id, "article") + + return article + + def get_or_create_category(self, name): + # Search for existing category + params = {'search': f'#category #categoryName={name}'} + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + results = response.json()['results'] + if results: + return results[0]['noteId'] + + # Create new category + data = { + "parentNoteId": "root", + "title": name, + "type": "text", + "content": f"

{name}

" + } + + response = requests.post( + f"{self.base_url}/create-note", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + + category_id = response.json()['note']['noteId'] + + self.add_label(category_id, "category") + self.add_label(category_id, "categoryName", name) + + return category_id + + def search_articles(self, query): + params = { + 'search': f'#article {query}', + 'orderBy': 'relevancy' + } + + response = requests.get( + f"{self.base_url}/notes", + headers=self.headers, + params=params + ) + + return response.json()['results'] + + def add_label(self, note_id, name, value=""): + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value + } + + requests.post( + f"{self.base_url}/attributes", + headers={**self.headers, 'Content-Type': 'application/json'}, + json=data + ) + +# Usage +kb = KnowledgeBase("http://localhost:8080/etapi", "your-token") + +# Add articles +article = kb.create_article( + category="Python", + title="Working with REST APIs", + content=""" +

Introduction

+

REST APIs are fundamental to modern web development...

+

Best Practices

+
    +
  • Use proper HTTP methods
  • +
  • Handle errors gracefully
  • +
  • Implement retry logic
  • +
+ """, + tags=["api", "rest", "tutorial"] +) + +# Search articles +results = kb.search_articles("REST API") +for article in results: + print(f"Found: {article['title']}") +``` + +## Client Library Examples + +### JavaScript/TypeScript Client + +```typescript +class TriliumClient { + private baseUrl: string; + private token: string; + + constructor(baseUrl: string, token: string) { + this.baseUrl = baseUrl; + this.token = token; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const headers = { + 'Authorization': this.token, + 'Content-Type': 'application/json', + ...options.headers + }; + + const response = await fetch(url, { + ...options, + headers + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(`API Error: ${error.message}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); + } + + async getNote(noteId: string) { + return this.request(`/notes/${noteId}`); + } + + async createNote(data: any) { + return this.request('/create-note', { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async updateNote(noteId: string, updates: any) { + return this.request(`/notes/${noteId}`, { + method: 'PATCH', + body: JSON.stringify(updates) + }); + } + + async deleteNote(noteId: string) { + return this.request(`/notes/${noteId}`, { + method: 'DELETE' + }); + } + + async searchNotes(query: string, options: any = {}) { + const params = new URLSearchParams({ + search: query, + ...options + }); + + return this.request(`/notes?${params}`); + } + + async addAttribute(noteId: string, type: string, name: string, value = '') { + return this.request('/attributes', { + method: 'POST', + body: JSON.stringify({ + noteId, + type, + name, + value + }) + }); + } +} + +// Usage +const client = new TriliumClient('http://localhost:8080/etapi', 'your-token'); + +// Create a note +const note = await client.createNote({ + parentNoteId: 'root', + title: 'New Note', + type: 'text', + content: '

Content

' +}); + +// Search notes +const results = await client.searchNotes('#todo', { + orderBy: 'dateModified', + orderDirection: 'desc', + limit: 10 +}); + +// Add a label +await client.addAttribute(note.note.noteId, 'label', 'important'); +``` + +### Python Client Class + +```python +import requests +from typing import Optional, Dict, List, Any +from datetime import datetime +import json + +class TriliumETAPI: + """Python client for Trilium ETAPI""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url.rstrip('/') + self.session = requests.Session() + self.session.headers.update({ + 'Authorization': token, + 'Content-Type': 'application/json' + }) + + def _request(self, method: str, endpoint: str, **kwargs) -> Any: + """Make API request with error handling""" + url = f"{self.base_url}{endpoint}" + + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + + if response.status_code == 204: + return None + + return response.json() if response.content else None + + except requests.exceptions.HTTPError as e: + if response.text: + try: + error = response.json() + raise Exception(f"API Error {error.get('code')}: {error.get('message')}") + except json.JSONDecodeError: + raise Exception(f"HTTP {response.status_code}: {response.text}") + raise e + + # Note operations + def create_note( + self, + parent_note_id: str, + title: str, + content: str, + note_type: str = "text", + **kwargs + ) -> Dict: + """Create a new note""" + data = { + "parentNoteId": parent_note_id, + "title": title, + "type": note_type, + "content": content, + **kwargs + } + return self._request('POST', '/create-note', json=data) + + def get_note(self, note_id: str) -> Dict: + """Get note by ID""" + return self._request('GET', f'/notes/{note_id}') + + def update_note(self, note_id: str, updates: Dict) -> Dict: + """Update note properties""" + return self._request('PATCH', f'/notes/{note_id}', json=updates) + + def delete_note(self, note_id: str) -> None: + """Delete a note""" + self._request('DELETE', f'/notes/{note_id}') + + def get_note_content(self, note_id: str) -> str: + """Get note content""" + response = self.session.get(f"{self.base_url}/notes/{note_id}/content") + response.raise_for_status() + return response.text + + def update_note_content(self, note_id: str, content: str) -> None: + """Update note content""" + headers = {'Content-Type': 'text/plain'} + self.session.put( + f"{self.base_url}/notes/{note_id}/content", + data=content, + headers=headers + ).raise_for_status() + + # Search + def search_notes( + self, + query: str, + fast_search: bool = False, + include_archived: bool = False, + ancestor_note_id: Optional[str] = None, + order_by: Optional[str] = None, + order_direction: str = "asc", + limit: Optional[int] = None + ) -> List[Dict]: + """Search for notes""" + params = { + 'search': query, + 'fastSearch': fast_search, + 'includeArchivedNotes': include_archived + } + + if ancestor_note_id: + params['ancestorNoteId'] = ancestor_note_id + if order_by: + params['orderBy'] = order_by + params['orderDirection'] = order_direction + if limit: + params['limit'] = limit + + result = self._request('GET', '/notes', params=params) + return result.get('results', []) + + # Attributes + def add_label( + self, + note_id: str, + name: str, + value: str = "", + inheritable: bool = False + ) -> Dict: + """Add a label to a note""" + data = { + "noteId": note_id, + "type": "label", + "name": name, + "value": value, + "isInheritable": inheritable + } + return self._request('POST', '/attributes', json=data) + + def add_relation( + self, + note_id: str, + name: str, + target_note_id: str, + inheritable: bool = False + ) -> Dict: + """Add a relation to a note""" + data = { + "noteId": note_id, + "type": "relation", + "name": name, + "value": target_note_id, + "isInheritable": inheritable + } + return self._request('POST', '/attributes', json=data) + + def update_attribute(self, attribute_id: str, updates: Dict) -> Dict: + """Update an attribute""" + return self._request('PATCH', f'/attributes/{attribute_id}', json=updates) + + def delete_attribute(self, attribute_id: str) -> None: + """Delete an attribute""" + self._request('DELETE', f'/attributes/{attribute_id}') + + # Branches + def clone_note( + self, + note_id: str, + parent_note_id: str, + prefix: str = "" + ) -> Dict: + """Clone a note to another location""" + data = { + "noteId": note_id, + "parentNoteId": parent_note_id, + "prefix": prefix + } + return self._request('POST', '/branches', json=data) + + def move_note( + self, + note_id: str, + new_parent_id: str + ) -> None: + """Move a note to a new parent""" + # Get current branches + note = self.get_note(note_id) + + # Delete old branches + for branch_id in note['parentBranchIds']: + self._request('DELETE', f'/branches/{branch_id}') + + # Create new branch + self.clone_note(note_id, new_parent_id) + + # Special notes + def get_inbox(self, date: Optional[datetime] = None) -> Dict: + """Get inbox note for a date""" + if date is None: + date = datetime.now() + date_str = date.strftime('%Y-%m-%d') + return self._request('GET', f'/inbox/{date_str}') + + def get_day_note(self, date: Optional[datetime] = None) -> Dict: + """Get day note for a date""" + if date is None: + date = datetime.now() + date_str = date.strftime('%Y-%m-%d') + return self._request('GET', f'/calendar/days/{date_str}') + + # Utility + def get_app_info(self) -> Dict: + """Get application information""" + return self._request('GET', '/app-info') + + def create_backup(self, name: str) -> None: + """Create a backup""" + self._request('PUT', f'/backup/{name}') + + def export_subtree( + self, + note_id: str, + format: str = "html" + ) -> bytes: + """Export note subtree as ZIP""" + params = {'format': format} + response = self.session.get( + f"{self.base_url}/notes/{note_id}/export", + params=params + ) + response.raise_for_status() + return response.content + +# Example usage +if __name__ == "__main__": + # Initialize client + api = TriliumETAPI("http://localhost:8080/etapi", "your-token") + + # Create a note + note = api.create_note( + parent_note_id="root", + title="API Test Note", + content="

Created via Python client

" + ) + print(f"Created note: {note['note']['noteId']}") + + # Add labels + api.add_label(note['note']['noteId'], "test") + api.add_label(note['note']['noteId'], "priority", "high") + + # Search + results = api.search_notes("#test", limit=10) + for result in results: + print(f"Found: {result['title']}") + + # Export backup + backup_data = api.export_subtree("root") + with open("backup.zip", "wb") as f: + f.write(backup_data) +``` + +## Rate Limiting and Best Practices + +### Rate Limiting + +ETAPI implements rate limiting for authentication endpoints: + +* **Login endpoint**: Maximum 10 requests per IP per hour +* **Other endpoints**: No specific rate limits, but excessive requests may be throttled + +### Best Practices + +#### 1\. Connection Pooling + +Reuse HTTP connections for better performance: + +```python +import requests +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry + +session = requests.Session() +retry = Retry( + total=3, + backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504] +) +adapter = HTTPAdapter(max_retries=retry) +session.mount('http://', adapter) +session.mount('https://', adapter) +``` + +#### 2\. Batch Operations + +When possible, batch multiple operations: + +```python +def batch_create_notes(notes_data): + """Create multiple notes efficiently""" + created_notes = [] + + for data in notes_data: + note = api.create_note(**data) + created_notes.append(note) + + # Add small delay to avoid overwhelming server + time.sleep(0.1) + + return created_notes +``` + +#### 3\. Error Handling + +Implement robust error handling: + +```python +import time +from typing import Callable, Any + +def retry_on_error( + func: Callable, + max_retries: int = 3, + backoff_factor: float = 1.0 +) -> Any: + """Retry function with exponential backoff""" + for attempt in range(max_retries): + try: + return func() + except requests.exceptions.RequestException as e: + if attempt == max_retries - 1: + raise + + wait_time = backoff_factor * (2 ** attempt) + print(f"Request failed, retrying in {wait_time}s...") + time.sleep(wait_time) + +# Usage +note = retry_on_error( + lambda: api.create_note("root", "Title", "Content") +) +``` + +#### 4\. Caching + +Cache frequently accessed data: + +```python +from functools import lru_cache +from datetime import datetime, timedelta + +class CachedTriliumClient(TriliumETAPI): + def __init__(self, base_url: str, token: str): + super().__init__(base_url, token) + self._cache = {} + self._cache_times = {} + + def get_note_cached(self, note_id: str, max_age: int = 300): + """Get note with caching (max_age in seconds)""" + cache_key = f"note:{note_id}" + + if cache_key in self._cache: + cache_time = self._cache_times[cache_key] + if datetime.now() - cache_time < timedelta(seconds=max_age): + return self._cache[cache_key] + + note = self.get_note(note_id) + self._cache[cache_key] = note + self._cache_times[cache_key] = datetime.now() + + return note +``` + +#### 5\. Pagination for Large Results + +Handle large result sets with pagination: + +```python +def search_all_notes(api: TriliumETAPI, query: str, batch_size: int = 100): + """Search with pagination for large result sets""" + all_results = [] + offset = 0 + + while True: + results = api.search_notes( + query, + limit=batch_size, + order_by="dateCreated" + ) + + if not results: + break + + all_results.extend(results) + + if len(results) < batch_size: + break + + # Use the last note's date as reference for next batch + last_date = results[-1]['dateCreated'] + query_with_date = f"{query} dateCreated>{last_date}" + + return all_results +``` + +## Migration from Internal API + +### Key Differences + +| Aspect | Internal API | ETAPI | +| --- | --- | --- | +| **Purpose** | Trilium client communication | External integrations | +| **Authentication** | Session-based | Token-based | +| **Stability** | May change between versions | Stable interface | +| **CSRF Protection** | Required | Not required | +| **WebSocket** | Supported | Not available | +| **Documentation** | Limited | Comprehensive | + +### Migration Steps + +1. **Replace Authentication** + + ```python + # Old (Internal API) + session = requests.Session() + session.post('/api/login', data={'password': 'pass'}) + + # New (ETAPI) + headers = {'Authorization': 'etapi-token'} + ``` +2. **Update Endpoints** + + ```python + # Old + /api/notes/getNoteById/noteId + + # New + /etapi/notes/noteId + ``` +3. **Adjust Request/Response Format** + + ```python + # Old (may vary) + response = session.post('/api/notes/new', json={ + 'parentNoteId': 'root', + 'title': 'Title' + }) + + # New (standardized) + response = requests.post('/etapi/create-note', + headers=headers, + json={ + 'parentNoteId': 'root', + 'title': 'Title', + 'type': 'text', + 'content': '' + } + ) + ``` + +## Error Handling + +### Common Error Codes + +| Status | Code | Description | Resolution | +| --- | --- | --- | --- | +| 400 | BAD\_REQUEST | Invalid request format | Check request body and parameters | +| 401 | UNAUTHORIZED | Invalid or missing token | Verify authentication token | +| 404 | NOTE\_NOT\_FOUND | Note doesn't exist | Check note ID | +| 404 | BRANCH\_NOT\_FOUND | Branch doesn't exist | Verify branch ID | +| 400 | NOTE\_IS\_PROTECTED | Cannot modify protected note | Unlock protected session first | +| 429 | TOO\_MANY\_REQUESTS | Rate limit exceeded | Wait before retrying | +| 500 | INTERNAL\_ERROR | Server error | Report issue, check logs | + +### Error Response Format + +```json +{ + "status": 400, + "code": "VALIDATION_ERROR", + "message": "Note title cannot be empty" +} +``` + +### Handling Errors in Code + +```python +class ETAPIError(Exception): + def __init__(self, status, code, message): + self.status = status + self.code = code + self.message = message + super().__init__(f"{code}: {message}") + +def handle_api_response(response): + if response.status_code >= 400: + try: + error = response.json() + raise ETAPIError( + error.get('status'), + error.get('code'), + error.get('message') + ) + except json.JSONDecodeError: + raise ETAPIError( + response.status_code, + 'UNKNOWN_ERROR', + response.text + ) + + return response.json() if response.content else None + +# Usage +try: + response = requests.get( + 'http://localhost:8080/etapi/notes/invalid', + headers={'Authorization': 'token'} + ) + note = handle_api_response(response) +except ETAPIError as e: + if e.code == 'NOTE_NOT_FOUND': + print("Note doesn't exist") + else: + print(f"API Error: {e.message}") +``` + +## Performance Considerations + +### 1\. Minimize API Calls + +```python +# Bad: Multiple calls +note = api.get_note(note_id) +for child_id in note['childNoteIds']: + child = api.get_note(child_id) # N+1 problem + process(child) + +# Good: Batch processing +note = api.get_note(note_id) +children = api.search_notes( + f"note.parents.noteId={note_id}", + limit=1000 +) +for child in children: + process(child) +``` + +### 2\. Use Appropriate Search Depth + +```python +# Limit search depth for better performance +results = api.search_notes( + "keyword", + ancestor_note_id="root", + ancestor_depth="lt3" # Only search 3 levels deep +) +``` + +### 3\. Content Compression + +Enable gzip compression for large responses: + +```python +session = requests.Session() +session.headers.update({ + 'Authorization': 'token', + 'Accept-Encoding': 'gzip, deflate' +}) +``` + +### 4\. Async Operations + +Use async requests for parallel operations: + +```python +import asyncio +import aiohttp + +class AsyncTriliumClient: + def __init__(self, base_url: str, token: str): + self.base_url = base_url + self.headers = {'Authorization': token} + + async def get_note(self, session, note_id): + url = f"{self.base_url}/notes/{note_id}" + async with session.get(url, headers=self.headers) as response: + return await response.json() + + async def get_multiple_notes(self, note_ids): + async with aiohttp.ClientSession() as session: + tasks = [self.get_note(session, nid) for nid in note_ids] + return await asyncio.gather(*tasks) + +# Usage +client = AsyncTriliumClient("http://localhost:8080/etapi", "token") +notes = asyncio.run(client.get_multiple_notes(['id1', 'id2', 'id3'])) +``` + +### 5\. Database Optimization + +For bulk operations, consider: + +* Creating notes in batches +* Using transactions (via backup/restore) +* Indexing frequently searched attributes + +## Security Considerations + +### Token Management + +* Store tokens securely (environment variables, key vaults) +* Rotate tokens regularly +* Use separate tokens for different applications +* Never commit tokens to version control + +```python +import os +from dotenv import load_dotenv + +load_dotenv() + +# Load token from environment +TOKEN = os.getenv('TRILIUM_ETAPI_TOKEN') +if not TOKEN: + raise ValueError("TRILIUM_ETAPI_TOKEN not set") + +api = TriliumETAPI("http://localhost:8080/etapi", TOKEN) +``` + +### HTTPS Usage + +Always use HTTPS in production: + +```python +# Development +dev_api = TriliumETAPI("http://localhost:8080/etapi", token) + +# Production +prod_api = TriliumETAPI("https://notes.example.com/etapi", token) +``` + +### Input Validation + +Sanitize user input before sending to API: + +```python +import html +import re + +def sanitize_html(content: str) -> str: + """Basic HTML sanitization""" + # Remove script tags + content = re.sub(r']*>.*?', '', content, flags=re.DOTALL) + # Remove on* attributes + content = re.sub(r'\s*on\w+\s*=\s*["\'][^"\']*["\']', '', content) + return content + +def create_safe_note(title: str, content: str): + safe_title = html.escape(title) + safe_content = sanitize_html(content) + + return api.create_note( + parent_note_id="root", + title=safe_title, + content=safe_content + ) +``` + +## Troubleshooting + +### Connection Issues + +```python +# Test connection +def test_connection(base_url, token): + try: + api = TriliumETAPI(base_url, token) + info = api.get_app_info() + print(f"Connected to Trilium {info['appVersion']}") + return True + except Exception as e: + print(f"Connection failed: {e}") + return False + +# Debug mode +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +### Common Issues and Solutions + +| Issue | Cause | Solution | +| --- | --- | --- | +| 401 Unauthorized | Invalid token | Regenerate token in Trilium Options | +| Connection refused | Server not running | Start Trilium server | +| CORS errors | Cross-origin requests | Configure CORS in Trilium settings | +| Timeout errors | Large operations | Increase timeout, use async | +| 404 Not Found | Wrong endpoint | Check ETAPI prefix in URL | +| Protected note error | Note is encrypted | Enter protected session first | + +## Additional Resources + +* [Trilium GitHub Repository](https://github.com/TriliumNext/Trilium) +* [OpenAPI Specification](#root/euAWtBArCWdw) +* [Trilium Search Documentation](https://triliumnext.github.io/Docs/Wiki/search.html) +* [Community Forum](https://github.com/TriliumNext/Trilium/discussions) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md b/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md new file mode 100644 index 00000000000..a397b113d8a --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/Internal API Reference.md @@ -0,0 +1,1926 @@ +# Internal API Reference +## Table of Contents + +1. [Introduction](#introduction) +2. [Authentication and Session Management](#authentication-and-session-management) +3. [Core API Endpoints](#core-api-endpoints) +4. [WebSocket Real-time Updates](#websocket-real-time-updates) +5. [File Operations](#file-operations) +6. [Import/Export Operations](#import-export-operations) +7. [Synchronization API](#synchronization-api) +8. [When to Use Internal vs ETAPI](#when-to-use-internal-vs-etapi) +9. [Security Considerations](#security-considerations) + +## Introduction + +The Internal API is the primary interface used by the Trilium Notes client application to communicate with the server. While powerful and feature-complete, this API is primarily designed for internal use. + +### Important Notice + +**For external integrations, please use [ETAPI](ETAPI%20Complete%20Guide.md) instead.** The Internal API: + +* May change between versions without notice +* Requires session-based authentication with CSRF protection +* Is tightly coupled with the frontend application +* Has limited documentation and stability guarantees + +### Base URL + +``` +http://localhost:8080/api +``` + +### Key Characteristics + +* Session-based authentication with cookies +* CSRF token protection for state-changing operations +* WebSocket support for real-time updates +* Full feature parity with the Trilium UI +* Complex request/response formats optimized for the client + +## Authentication and Session Management + +### Password Login + +**POST** `/api/login` + +Authenticates user with password and creates a session. + +**Request:** + +```javascript +const formData = new URLSearchParams(); +formData.append('password', 'your-password'); + +const response = await fetch('http://localhost:8080/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData, + credentials: 'include' // Important for cookie handling +}); +``` + +**Response:** + +```json +{ + "success": true, + "message": "Login successful" +} +``` + +The server sets a session cookie (`trilium.sid`) that must be included in subsequent requests. + +### TOTP Authentication (2FA) + +If 2FA is enabled, include the TOTP token: + +```javascript +formData.append('password', 'your-password'); +formData.append('totpToken', '123456'); +``` + +### Token Authentication + +**POST** `/api/login/token` + +Generate an API token for programmatic access: + +```javascript +const response = await fetch('http://localhost:8080/api/login/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + password: 'your-password', + tokenName: 'My Integration' + }) +}); + +const { authToken } = await response.json(); +// Use this token in Authorization header for future requests +``` + +### Protected Session + +**POST** `/api/login/protected` + +Enter protected session to access encrypted notes: + +```javascript +await fetch('http://localhost:8080/api/login/protected', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + password: 'your-password' + }), + credentials: 'include' +}); +``` + +### Logout + +**POST** `/api/logout` + +```javascript +await fetch('http://localhost:8080/api/logout', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +## Core API Endpoints + +### Notes + +#### Get Note + +**GET** `/api/notes/{noteId}` + +```javascript +const response = await fetch('http://localhost:8080/api/notes/root', { + credentials: 'include' +}); + +const note = await response.json(); +``` + +**Response:** + +```json +{ + "noteId": "root", + "title": "Trilium Notes", + "type": "text", + "mime": "text/html", + "isProtected": false, + "isDeleted": false, + "dateCreated": "2024-01-01 00:00:00.000+0000", + "dateModified": "2024-01-15 10:30:00.000+0000", + "utcDateCreated": "2024-01-01 00:00:00.000Z", + "utcDateModified": "2024-01-15 10:30:00.000Z", + "parentBranches": [ + { + "branchId": "root_root", + "parentNoteId": "none", + "prefix": null, + "notePosition": 10 + } + ], + "attributes": [], + "cssClass": "", + "iconClass": "bx bx-folder" +} +``` + +#### Create Note + +**POST** `/api/notes/{parentNoteId}/children` + +```javascript +const response = await fetch('http://localhost:8080/api/notes/root/children', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + title: 'New Note', + type: 'text', + content: '

Note content

', + isProtected: false + }), + credentials: 'include' +}); + +const { note, branch } = await response.json(); +``` + +#### Update Note + +**PUT** `/api/notes/{noteId}` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + title: 'Updated Title', + type: 'text', + mime: 'text/html' + }), + credentials: 'include' +}); +``` + +#### Delete Note + +**DELETE** `/api/notes/{noteId}` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +#### Get Note Content + +**GET** `/api/notes/{noteId}/content` + +Returns the actual content of the note: + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/content`, { + credentials: 'include' +}); + +const content = await response.text(); +``` + +#### Save Note Content + +**PUT** `/api/notes/{noteId}/content` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/html', + 'X-CSRF-Token': csrfToken + }, + body: '

Updated content

', + credentials: 'include' +}); +``` + +### Tree Operations + +#### Get Branch + +**GET** `/api/branches/{branchId}` + +```javascript +const branch = await fetch(`http://localhost:8080/api/branches/${branchId}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Move Note + +**PUT** `/api/branches/{branchId}/move` + +```javascript +await fetch(`http://localhost:8080/api/branches/${branchId}/move`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'newParentId', + beforeNoteId: 'siblingNoteId' // optional, for positioning + }), + credentials: 'include' +}); +``` + +#### Clone Note + +**POST** `/api/notes/{noteId}/clone` + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/clone`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'targetParentId', + prefix: 'Copy of ' + }), + credentials: 'include' +}); +``` + +#### Sort Child Notes + +**PUT** `/api/notes/{noteId}/sort-children` + +```javascript +await fetch(`http://localhost:8080/api/notes/${noteId}/sort-children`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + sortBy: 'title', // or 'dateCreated', 'dateModified' + reverse: false + }), + credentials: 'include' +}); +``` + +### Attributes + +#### Create Attribute + +**POST** `/api/notes/{noteId}/attributes` + +```javascript +const response = await fetch(`http://localhost:8080/api/notes/${noteId}/attributes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + type: 'label', + name: 'todo', + value: '', + isInheritable: false + }), + credentials: 'include' +}); +``` + +#### Update Attribute + +**PUT** `/api/attributes/{attributeId}` + +```javascript +await fetch(`http://localhost:8080/api/attributes/${attributeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + value: 'updated value' + }), + credentials: 'include' +}); +``` + +#### Delete Attribute + +**DELETE** `/api/attributes/{attributeId}` + +```javascript +await fetch(`http://localhost:8080/api/attributes/${attributeId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Search + +#### Search Notes + +**GET** `/api/search` + +```javascript +const params = new URLSearchParams({ + query: '#todo OR #task', + fastSearch: 'false', + includeArchivedNotes: 'false', + ancestorNoteId: 'root', + orderBy: 'relevancy', + orderDirection: 'desc', + limit: '50' +}); + +const response = await fetch(`http://localhost:8080/api/search?${params}`, { + credentials: 'include' +}); + +const { results } = await response.json(); +``` + +#### Search Note Map + +**GET** `/api/search-note-map` + +Returns hierarchical structure of search results: + +```javascript +const params = new URLSearchParams({ + query: 'project', + maxDepth: '3' +}); + +const noteMap = await fetch(`http://localhost:8080/api/search-note-map?${params}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Revisions + +#### Get Note Revisions + +**GET** `/api/notes/{noteId}/revisions` + +```javascript +const revisions = await fetch(`http://localhost:8080/api/notes/${noteId}/revisions`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Revision Content + +**GET** `/api/revisions/{revisionId}/content` + +```javascript +const content = await fetch(`http://localhost:8080/api/revisions/${revisionId}/content`, { + credentials: 'include' +}).then(r => r.text()); +``` + +#### Restore Revision + +**POST** `/api/revisions/{revisionId}/restore` + +```javascript +await fetch(`http://localhost:8080/api/revisions/${revisionId}/restore`, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +#### Delete Revision + +**DELETE** `/api/revisions/{revisionId}` + +```javascript +await fetch(`http://localhost:8080/api/revisions/${revisionId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +## WebSocket Real-time Updates + +The Internal API provides WebSocket connections for real-time synchronization and updates. + +### Connection Setup + +```javascript +class TriliumWebSocket { + constructor() { + this.ws = null; + this.reconnectInterval = 5000; + this.shouldReconnect = true; + } + + connect() { + // WebSocket URL same as base URL but with ws:// protocol + const wsUrl = 'ws://localhost:8080'; + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.sendPing(); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + this.handleMessage(message); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + if (this.shouldReconnect) { + setTimeout(() => this.connect(), this.reconnectInterval); + } + }; + } + + handleMessage(message) { + switch (message.type) { + case 'sync': + this.handleSync(message.data); + break; + case 'entity-changes': + this.handleEntityChanges(message.data); + break; + case 'refresh-tree': + this.refreshTree(); + break; + case 'create-note': + this.handleNoteCreated(message.data); + break; + case 'update-note': + this.handleNoteUpdated(message.data); + break; + case 'delete-note': + this.handleNoteDeleted(message.data); + break; + default: + console.log('Unknown message type:', message.type); + } + } + + sendPing() { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'ping' })); + setTimeout(() => this.sendPing(), 30000); // Ping every 30 seconds + } + } + + send(type, data) { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type, data })); + } + } + + handleSync(data) { + // Handle synchronization data + console.log('Sync data received:', data); + } + + handleEntityChanges(changes) { + // Handle entity change notifications + changes.forEach(change => { + console.log(`Entity ${change.entityName} ${change.entityId} changed`); + }); + } + + refreshTree() { + // Refresh the note tree UI + console.log('Tree refresh requested'); + } + + handleNoteCreated(note) { + console.log('Note created:', note); + } + + handleNoteUpdated(note) { + console.log('Note updated:', note); + } + + handleNoteDeleted(noteId) { + console.log('Note deleted:', noteId); + } + + disconnect() { + this.shouldReconnect = false; + if (this.ws) { + this.ws.close(); + } + } +} + +// Usage +const ws = new TriliumWebSocket(); +ws.connect(); + +// Send custom message +ws.send('log-info', { info: 'Client started' }); + +// Clean up on page unload +window.addEventListener('beforeunload', () => { + ws.disconnect(); +}); +``` + +### Message Types + +#### Incoming Messages + +| Type | Description | Data Format | +| --- | --- | --- | +| `sync` | Synchronization data | `{ entityChanges: [], lastSyncedPush: number }` | +| `entity-changes` | Entity modifications | `[{ entityName, entityId, action }]` | +| `refresh-tree` | Tree structure changed | None | +| `create-note` | Note created | Note object | +| `update-note` | Note updated | Note object | +| `delete-note` | Note deleted | `{ noteId }` | +| `frontend-script` | Execute frontend script | `{ script, params }` | + +#### Outgoing Messages + +| Type | Description | Data Format | +| --- | --- | --- | +| `ping` | Keep connection alive | None | +| `log-error` | Log client error | `{ error, stack }` | +| `log-info` | Log client info | `{ info }` | + +### Real-time Collaboration Example + +```javascript +class CollaborativeEditor { + constructor(noteId) { + this.noteId = noteId; + this.ws = new TriliumWebSocket(); + this.content = ''; + this.lastSaved = ''; + + this.ws.handleNoteUpdated = (note) => { + if (note.noteId === this.noteId) { + this.handleRemoteUpdate(note); + } + }; + } + + async loadNote() { + const response = await fetch(`/api/notes/${this.noteId}/content`, { + credentials: 'include' + }); + this.content = await response.text(); + this.lastSaved = this.content; + } + + handleRemoteUpdate(note) { + // Check if the update is from another client + if (this.content !== this.lastSaved) { + // Show conflict resolution UI + this.showConflictDialog(note); + } else { + // Apply remote changes + this.loadNote(); + } + } + + async saveContent(content) { + this.content = content; + + await fetch(`/api/notes/${this.noteId}/content`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/html', + 'X-CSRF-Token': csrfToken + }, + body: content, + credentials: 'include' + }); + + this.lastSaved = content; + } + + showConflictDialog(remoteNote) { + // Implementation of conflict resolution UI + console.log('Conflict detected with remote changes'); + } +} +``` + +## File Operations + +### Upload File + +**POST** `/api/notes/{noteId}/attachments/upload` + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +const response = await fetch(`/api/notes/${noteId}/attachments/upload`, { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const attachment = await response.json(); +``` + +### Download Attachment + +**GET** `/api/attachments/{attachmentId}/download` + +```javascript +const response = await fetch(`/api/attachments/${attachmentId}/download`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +const a = document.createElement('a'); +a.href = url; +a.download = 'attachment.pdf'; +a.click(); +``` + +### Upload Image + +**POST** `/api/images/upload` + +```javascript +const formData = new FormData(); +formData.append('image', imageFile); +formData.append('noteId', noteId); + +const response = await fetch('/api/images/upload', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const { url, noteId: imageNoteId } = await response.json(); +``` + +## Import/Export Operations + +### Import ZIP + +**POST** `/api/import` + +```javascript +const formData = new FormData(); +formData.append('file', zipFile); +formData.append('parentNoteId', 'root'); + +const response = await fetch('/api/import', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + body: formData, + credentials: 'include' +}); + +const result = await response.json(); +``` + +### Export Subtree + +**GET** `/api/notes/{noteId}/export` + +```javascript +const params = new URLSearchParams({ + format: 'html', // or 'markdown' + exportRevisions: 'true' +}); + +const response = await fetch(`/api/notes/${noteId}/export?${params}`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +const a = document.createElement('a'); +a.href = url; +a.download = 'export.zip'; +a.click(); +``` + +### Import Markdown + +**POST** `/api/import/markdown` + +```javascript +const response = await fetch('/api/import/markdown', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + parentNoteId: 'root', + content: '# Markdown Content\n\nParagraph text...', + title: 'Imported from Markdown' + }), + credentials: 'include' +}); +``` + +### Export as PDF + +**GET** `/api/notes/{noteId}/export/pdf` + +```javascript +const response = await fetch(`/api/notes/${noteId}/export/pdf`, { + credentials: 'include' +}); + +const blob = await response.blob(); +const url = URL.createObjectURL(blob); +window.open(url, '_blank'); +``` + +## Synchronization API + +### Get Sync Status + +**GET** `/api/sync/status` + +```javascript +const status = await fetch('/api/sync/status', { + credentials: 'include' +}).then(r => r.json()); + +console.log('Sync enabled:', status.syncEnabled); +console.log('Last sync:', status.lastSyncedPush); +``` + +### Force Sync + +**POST** `/api/sync/now` + +```javascript +await fetch('/api/sync/now', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Get Sync Log + +**GET** `/api/sync/log` + +```javascript +const log = await fetch('/api/sync/log', { + credentials: 'include' +}).then(r => r.json()); + +log.forEach(entry => { + console.log(`${entry.date}: ${entry.message}`); +}); +``` + +## Script Execution + +### Execute Script + +**POST** `/api/script/run` + +```javascript +const response = await fetch('/api/script/run', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + script: ` + const note = await api.getNote('root'); + return { title: note.title, children: note.children.length }; + `, + params: {} + }), + credentials: 'include' +}); + +const result = await response.json(); +``` + +### Execute Note Script + +**POST** `/api/notes/{noteId}/run` + +Run a script note: + +```javascript +const response = await fetch(`/api/notes/${scriptNoteId}/run`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + params: { + targetNoteId: 'someNoteId' + } + }), + credentials: 'include' +}); + +const result = await response.json(); +``` + +## Special Features + +### Calendar API + +#### Get Day Note + +**GET** `/api/calendar/days/{date}` + +```javascript +const date = '2024-01-15'; +const dayNote = await fetch(`/api/calendar/days/${date}`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Week Note + +**GET** `/api/calendar/weeks/{date}` + +```javascript +const weekNote = await fetch(`/api/calendar/weeks/2024-01-15`, { + credentials: 'include' +}).then(r => r.json()); +``` + +#### Get Month Note + +**GET** `/api/calendar/months/{month}` + +```javascript +const monthNote = await fetch(`/api/calendar/months/2024-01`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Inbox Note + +**GET** `/api/inbox/{date}` + +```javascript +const inboxNote = await fetch(`/api/inbox/2024-01-15`, { + credentials: 'include' +}).then(r => r.json()); +``` + +### Note Map + +**GET** `/api/notes/{noteId}/map` + +Get visual map data for a note: + +```javascript +const mapData = await fetch(`/api/notes/${noteId}/map`, { + credentials: 'include' +}).then(r => r.json()); + +// Returns nodes and links for visualization +console.log('Nodes:', mapData.nodes); +console.log('Links:', mapData.links); +``` + +### Similar Notes + +**GET** `/api/notes/{noteId}/similar` + +Find notes similar to the given note: + +```javascript +const similarNotes = await fetch(`/api/notes/${noteId}/similar`, { + credentials: 'include' +}).then(r => r.json()); +``` + +## Options and Configuration + +### Get All Options + +**GET** `/api/options` + +```javascript +const options = await fetch('/api/options', { + credentials: 'include' +}).then(r => r.json()); +``` + +### Update Option + +**PUT** `/api/options/{optionName}` + +```javascript +await fetch(`/api/options/theme`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + value: 'dark' + }), + credentials: 'include' +}); +``` + +### Get User Preferences + +**GET** `/api/options/user` + +```javascript +const preferences = await fetch('/api/options/user', { + credentials: 'include' +}).then(r => r.json()); +``` + +## Database Operations + +### Backup Database + +**POST** `/api/database/backup` + +```javascript +const response = await fetch('/api/database/backup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + backupName: 'manual-backup' + }), + credentials: 'include' +}); + +const { backupFile } = await response.json(); +``` + +### Vacuum Database + +**POST** `/api/database/vacuum` + +```javascript +await fetch('/api/database/vacuum', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + }, + credentials: 'include' +}); +``` + +### Get Database Info + +**GET** `/api/database/info` + +```javascript +const info = await fetch('/api/database/info', { + credentials: 'include' +}).then(r => r.json()); + +console.log('Database size:', info.size); +console.log('Note count:', info.noteCount); +console.log('Revision count:', info.revisionCount); +``` + +## When to Use Internal vs ETAPI + +### Use Internal API When: + +* Building custom Trilium clients +* Needing WebSocket real-time updates +* Requiring full feature parity with the UI +* Working within the Trilium frontend environment +* Accessing advanced features not available in ETAPI + +### Use ETAPI When: + +* Building external integrations +* Creating automation scripts +* Developing third-party applications +* Needing stable, documented API +* Working with different programming languages + +### Feature Comparison + +| Feature | Internal API | ETAPI | +| --- | --- | --- | +| **Authentication** | Session/Cookie | Token | +| **CSRF Protection** | Required | Not needed | +| **WebSocket** | Yes | No | +| **Stability** | May change | Stable | +| **Documentation** | Limited | Comprehensive | +| **Real-time updates** | Yes | No | +| **File uploads** | Complex | Simple | +| **Scripting** | Full support | Limited | +| **Synchronization** | Yes | No | + +## Security Considerations + +### CSRF Protection + +All state-changing operations require a CSRF token: + +```javascript +// Get CSRF token from meta tag or API +async function getCsrfToken() { + const response = await fetch('/api/csrf-token', { + credentials: 'include' + }); + const { token } = await response.json(); + return token; +} + +// Use in requests +const csrfToken = await getCsrfToken(); + +await fetch('/api/notes', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(data), + credentials: 'include' +}); +``` + +### Session Management + +```javascript +class TriliumSession { + constructor() { + this.isAuthenticated = false; + this.csrfToken = null; + } + + async login(password) { + const formData = new URLSearchParams(); + formData.append('password', password); + + const response = await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: formData, + credentials: 'include' + }); + + if (response.ok) { + this.isAuthenticated = true; + this.csrfToken = await this.getCsrfToken(); + return true; + } + + return false; + } + + async getCsrfToken() { + const response = await fetch('/api/csrf-token', { + credentials: 'include' + }); + const { token } = await response.json(); + return token; + } + + async request(url, options = {}) { + if (!this.isAuthenticated) { + throw new Error('Not authenticated'); + } + + const headers = { + ...options.headers + }; + + if (options.method && options.method !== 'GET') { + headers['X-CSRF-Token'] = this.csrfToken; + } + + return fetch(url, { + ...options, + headers, + credentials: 'include' + }); + } + + async logout() { + await this.request('/api/logout', { method: 'POST' }); + this.isAuthenticated = false; + this.csrfToken = null; + } +} + +// Usage +const session = new TriliumSession(); +await session.login('password'); + +// Make authenticated requests +const notes = await session.request('/api/notes/root').then(r => r.json()); + +// Create note with CSRF protection +await session.request('/api/notes/root/children', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'New Note', type: 'text' }) +}); + +await session.logout(); +``` + +### Protected Notes + +Handle encrypted notes properly: + +```javascript +class ProtectedNoteHandler { + constructor(session) { + this.session = session; + this.protectedSessionTimeout = null; + } + + async enterProtectedSession(password) { + const response = await this.session.request('/api/login/protected', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + if (response.ok) { + // Protected session expires after inactivity + this.resetProtectedSessionTimeout(); + return true; + } + + return false; + } + + resetProtectedSessionTimeout() { + if (this.protectedSessionTimeout) { + clearTimeout(this.protectedSessionTimeout); + } + + // Assume 5 minute timeout + this.protectedSessionTimeout = setTimeout(() => { + console.log('Protected session expired'); + this.onProtectedSessionExpired(); + }, 5 * 60 * 1000); + } + + async accessProtectedNote(noteId) { + try { + const note = await this.session.request(`/api/notes/${noteId}`) + .then(r => r.json()); + + if (note.isProtected) { + // Reset timeout on successful access + this.resetProtectedSessionTimeout(); + } + + return note; + } catch (error) { + if (error.message.includes('Protected session required')) { + // Prompt for password + const password = await this.promptForPassword(); + if (await this.enterProtectedSession(password)) { + return this.accessProtectedNote(noteId); + } + } + throw error; + } + } + + async promptForPassword() { + // Implementation depends on UI framework + return prompt('Enter password for protected notes:'); + } + + onProtectedSessionExpired() { + // Handle expiration (e.g., show notification, lock UI) + console.log('Please re-enter password to access protected notes'); + } +} +``` + +## Error Handling + +### Common Error Responses + +```javascript +// 401 Unauthorized +{ + "status": 401, + "message": "Authentication required" +} + +// 403 Forbidden +{ + "status": 403, + "message": "CSRF token validation failed" +} + +// 404 Not Found +{ + "status": 404, + "message": "Note 'invalidId' not found" +} + +// 400 Bad Request +{ + "status": 400, + "message": "Invalid note type: 'invalid'" +} + +// 500 Internal Server Error +{ + "status": 500, + "message": "Database error", + "stack": "..." // Only in development +} +``` + +### Error Handler Implementation + +```javascript +class APIErrorHandler { + async handleResponse(response) { + if (!response.ok) { + const error = await this.parseError(response); + + switch (response.status) { + case 401: + this.handleAuthError(error); + break; + case 403: + this.handleForbiddenError(error); + break; + case 404: + this.handleNotFoundError(error); + break; + case 400: + this.handleBadRequestError(error); + break; + case 500: + this.handleServerError(error); + break; + default: + this.handleGenericError(error); + } + + throw error; + } + + return response; + } + + async parseError(response) { + try { + const errorData = await response.json(); + return new APIError( + response.status, + errorData.message || response.statusText, + errorData + ); + } catch { + return new APIError( + response.status, + response.statusText + ); + } + } + + handleAuthError(error) { + console.error('Authentication required'); + // Redirect to login + window.location.href = '/login'; + } + + handleForbiddenError(error) { + if (error.message.includes('CSRF')) { + console.error('CSRF token invalid, refreshing...'); + // Refresh CSRF token + this.refreshCsrfToken(); + } else { + console.error('Access forbidden:', error.message); + } + } + + handleNotFoundError(error) { + console.error('Resource not found:', error.message); + } + + handleBadRequestError(error) { + console.error('Bad request:', error.message); + } + + handleServerError(error) { + console.error('Server error:', error.message); + // Show user-friendly error message + this.showErrorNotification('An error occurred. Please try again later.'); + } + + handleGenericError(error) { + console.error('API error:', error); + } + + showErrorNotification(message) { + // Implementation depends on UI framework + alert(message); + } +} + +class APIError extends Error { + constructor(status, message, data = {}) { + super(message); + this.status = status; + this.data = data; + this.name = 'APIError'; + } +} +``` + +## Performance Optimization + +### Request Batching + +```javascript +class BatchedAPIClient { + constructor() { + this.batchQueue = []; + this.batchTimeout = null; + this.batchDelay = 50; // ms + } + + async batchRequest(request) { + return new Promise((resolve, reject) => { + this.batchQueue.push({ request, resolve, reject }); + + if (!this.batchTimeout) { + this.batchTimeout = setTimeout(() => { + this.processBatch(); + }, this.batchDelay); + } + }); + } + + async processBatch() { + const batch = this.batchQueue.splice(0); + this.batchTimeout = null; + + if (batch.length === 0) return; + + try { + const response = await fetch('/api/batch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + requests: batch.map(b => b.request) + }), + credentials: 'include' + }); + + const results = await response.json(); + + batch.forEach((item, index) => { + if (results[index].error) { + item.reject(new Error(results[index].error)); + } else { + item.resolve(results[index].data); + } + }); + } catch (error) { + batch.forEach(item => item.reject(error)); + } + } + + async getNote(noteId) { + return this.batchRequest({ + method: 'GET', + url: `/api/notes/${noteId}` + }); + } + + async getAttribute(attributeId) { + return this.batchRequest({ + method: 'GET', + url: `/api/attributes/${attributeId}` + }); + } +} + +// Usage +const client = new BatchedAPIClient(); + +// These requests will be batched +const [note1, note2, note3] = await Promise.all([ + client.getNote('noteId1'), + client.getNote('noteId2'), + client.getNote('noteId3') +]); +``` + +### Caching Strategy + +```javascript +class CachedAPIClient { + constructor() { + this.cache = new Map(); + this.cacheExpiry = new Map(); + this.defaultTTL = 5 * 60 * 1000; // 5 minutes + } + + getCacheKey(method, url, params = {}) { + return `${method}:${url}:${JSON.stringify(params)}`; + } + + isExpired(key) { + const expiry = this.cacheExpiry.get(key); + return !expiry || Date.now() > expiry; + } + + async cachedRequest(method, url, options = {}, ttl = this.defaultTTL) { + const key = this.getCacheKey(method, url, options.params); + + if (method === 'GET' && this.cache.has(key) && !this.isExpired(key)) { + return this.cache.get(key); + } + + const response = await fetch(url, { + method, + ...options, + credentials: 'include' + }); + + const data = await response.json(); + + if (method === 'GET') { + this.cache.set(key, data); + this.cacheExpiry.set(key, Date.now() + ttl); + } + + return data; + } + + invalidate(pattern) { + for (const key of this.cache.keys()) { + if (key.includes(pattern)) { + this.cache.delete(key); + this.cacheExpiry.delete(key); + } + } + } + + async getNote(noteId) { + return this.cachedRequest('GET', `/api/notes/${noteId}`); + } + + async updateNote(noteId, data) { + const result = await fetch(`/api/notes/${noteId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(data), + credentials: 'include' + }).then(r => r.json()); + + // Invalidate cache for this note + this.invalidate(`/api/notes/${noteId}`); + + return result; + } +} +``` + +## Advanced Examples + +### Building a Note Explorer + +```javascript +class NoteExplorer { + constructor() { + this.currentNote = null; + this.history = []; + this.historyIndex = -1; + } + + async navigateToNote(noteId) { + // Add to history + if (this.historyIndex < this.history.length - 1) { + this.history = this.history.slice(0, this.historyIndex + 1); + } + this.history.push(noteId); + this.historyIndex++; + + // Load note + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + + async loadNoteWithChildren(noteId) { + const [note, children] = await Promise.all([ + fetch(`/api/notes/${noteId}`, { credentials: 'include' }) + .then(r => r.json()), + fetch(`/api/notes/${noteId}/children`, { credentials: 'include' }) + .then(r => r.json()) + ]); + + return { ...note, children }; + } + + canGoBack() { + return this.historyIndex > 0; + } + + canGoForward() { + return this.historyIndex < this.history.length - 1; + } + + async goBack() { + if (this.canGoBack()) { + this.historyIndex--; + const noteId = this.history[this.historyIndex]; + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + } + + async goForward() { + if (this.canGoForward()) { + this.historyIndex++; + const noteId = this.history[this.historyIndex]; + this.currentNote = await this.loadNoteWithChildren(noteId); + this.render(); + } + } + + async searchInSubtree(query) { + const params = new URLSearchParams({ + query: query, + ancestorNoteId: this.currentNote.noteId, + includeArchivedNotes: 'false' + }); + + const response = await fetch(`/api/search?${params}`, { + credentials: 'include' + }); + + return response.json(); + } + + async createChildNote(title, content, type = 'text') { + const response = await fetch(`/api/notes/${this.currentNote.noteId}/children`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': await getCsrfToken() + }, + body: JSON.stringify({ title, content, type }), + credentials: 'include' + }); + + const result = await response.json(); + + // Refresh current note to show new child + this.currentNote = await this.loadNoteWithChildren(this.currentNote.noteId); + this.render(); + + return result; + } + + render() { + // Render UI - implementation depends on framework + console.log('Current note:', this.currentNote.title); + console.log('Children:', this.currentNote.children.map(c => c.title)); + } +} + +// Usage +const explorer = new NoteExplorer(); +await explorer.navigateToNote('root'); +await explorer.createChildNote('New Child', '

Content

'); +const searchResults = await explorer.searchInSubtree('keyword'); +``` + +### Building a Task Management System + +```javascript +class TaskManager { + constructor() { + this.taskRootId = null; + this.csrfToken = null; + } + + async initialize() { + this.csrfToken = await getCsrfToken(); + this.taskRootId = await this.getOrCreateTaskRoot(); + } + + async getOrCreateTaskRoot() { + // Search for existing task root + const searchParams = new URLSearchParams({ query: '#taskRoot' }); + const searchResponse = await fetch(`/api/search?${searchParams}`, { + credentials: 'include' + }); + const { results } = await searchResponse.json(); + + if (results.length > 0) { + return results[0].noteId; + } + + // Create task root + const response = await fetch('/api/notes/root/children', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + title: 'Tasks', + type: 'text', + content: '

Task Management

' + }), + credentials: 'include' + }); + + const { note } = await response.json(); + + // Add taskRoot label + await this.addLabel(note.noteId, 'taskRoot'); + + return note.noteId; + } + + async createTask(title, description, priority = 'medium', dueDate = null) { + // Create task note + const response = await fetch(`/api/notes/${this.taskRootId}/children`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + title, + type: 'text', + content: `

${title}

${description}

` + }), + credentials: 'include' + }); + + const { note } = await response.json(); + + // Add task metadata + await Promise.all([ + this.addLabel(note.noteId, 'task'), + this.addLabel(note.noteId, 'status', 'todo'), + this.addLabel(note.noteId, 'priority', priority), + dueDate ? this.addLabel(note.noteId, 'dueDate', dueDate) : null + ].filter(Boolean)); + + return note; + } + + async addLabel(noteId, name, value = '') { + await fetch(`/api/notes/${noteId}/attributes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ + type: 'label', + name, + value, + isInheritable: false + }), + credentials: 'include' + }); + } + + async getTasks(status = null, priority = null) { + let query = '#task'; + if (status) query += ` #status=${status}`; + if (priority) query += ` #priority=${priority}`; + + const params = new URLSearchParams({ + query, + ancestorNoteId: this.taskRootId, + orderBy: 'dateModified', + orderDirection: 'desc' + }); + + const response = await fetch(`/api/search?${params}`, { + credentials: 'include' + }); + + const { results } = await response.json(); + return results; + } + + async updateTaskStatus(noteId, newStatus) { + // Get task attributes + const note = await fetch(`/api/notes/${noteId}`, { + credentials: 'include' + }).then(r => r.json()); + + // Find status attribute + const statusAttr = note.attributes.find(a => a.name === 'status'); + + if (statusAttr) { + // Update existing status + await fetch(`/api/attributes/${statusAttr.attributeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': this.csrfToken + }, + body: JSON.stringify({ value: newStatus }), + credentials: 'include' + }); + } else { + // Add status attribute + await this.addLabel(noteId, 'status', newStatus); + } + + // Add completion timestamp if marking as done + if (newStatus === 'done') { + const timestamp = new Date().toISOString(); + await this.addLabel(noteId, 'completedAt', timestamp); + } + } + + async getTaskStats() { + const [todoTasks, inProgressTasks, doneTasks] = await Promise.all([ + this.getTasks('todo'), + this.getTasks('in-progress'), + this.getTasks('done') + ]); + + return { + todo: todoTasks.length, + inProgress: inProgressTasks.length, + done: doneTasks.length, + total: todoTasks.length + inProgressTasks.length + doneTasks.length + }; + } +} + +// Usage +const taskManager = new TaskManager(); +await taskManager.initialize(); + +// Create tasks +const task1 = await taskManager.createTask( + 'Review Documentation', + 'Review and update API documentation', + 'high', + '2024-01-20' +); + +const task2 = await taskManager.createTask( + 'Fix Bug #123', + 'Investigate and fix the reported issue', + 'medium' +); + +// Get tasks +const todoTasks = await taskManager.getTasks('todo'); +console.log('Todo tasks:', todoTasks); + +// Update task status +await taskManager.updateTaskStatus(task1.noteId, 'in-progress'); + +// Get statistics +const stats = await taskManager.getTaskStats(); +console.log('Task statistics:', stats); +``` + +## Conclusion + +The Internal API provides complete access to Trilium's functionality but should be used with caution due to its complexity and potential for changes. For most external integrations, [ETAPI](ETAPI%20Complete%20Guide.md) is the recommended choice due to its stability and comprehensive documentation. + +Key takeaways: + +* Always include CSRF tokens for state-changing operations +* Handle session management carefully +* Use WebSocket for real-time updates +* Implement proper error handling +* Consider using ETAPI for external integrations +* Cache responses when appropriate for better performance + +For additional information, refer to: + +* [ETAPI Complete Guide](ETAPI%20Complete%20Guide.md) +* [Script API Cookbook](Script%20API%20Cookbook.md) +* [WebSocket API Documentation](WebSocket%20API.md) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md b/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md new file mode 100644 index 00000000000..03384447936 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/Script API Cookbook.md @@ -0,0 +1,1845 @@ +# Script API Cookbook +## Table of Contents + +1. [Introduction](#introduction) +2. [Backend Script Recipes](#backend-script-recipes) +3. [Frontend Script Recipes](#frontend-script-recipes) +4. [Common Patterns](#common-patterns) +5. [Note Manipulation](#note-manipulation) +6. [Attribute Operations](#attribute-operations) +7. [Search and Filtering](#search-and-filtering) +8. [Automation Examples](#automation-examples) +9. [Integration with External Services](#integration-with-external-services) +10. [Custom Widgets](#custom-widgets) +11. [Event Handling](#event-handling) +12. [Best Practices](#best-practices) + +## Introduction + +Trilium's Script API provides powerful automation capabilities through JavaScript code that runs either on the backend (Node.js) or frontend (browser). This cookbook contains practical recipes and patterns for common scripting tasks. + +### Script Types + +| Type | Environment | Access | Use Cases | +| --- | --- | --- | --- | +| **Backend Script** | Node.js | Full database, file system, network | Automation, data processing, integrations | +| **Frontend Script** | Browser | UI manipulation, user interaction | Custom widgets, UI enhancements | +| **Custom Widget** | Browser | Widget lifecycle, note context | Interactive components, visualizations | + +### Basic Script Structure + +**Backend Script:** + +```javascript +// Access to api object is automatic +const note = await api.getNoteWithLabel('todoList'); +const children = await note.getChildNotes(); + +// Return value becomes script output +return { + noteTitle: note.title, + childCount: children.length +}; +``` + +**Frontend Script:** + +```javascript +// Access to api object is automatic +api.showMessage('Script executed!'); + +// Manipulate UI +const $button = $(' + + + +`); + +// Create overlay +const $overlay = $(` +
+`); + +// Add to page +$('body').append($button, $overlay, $modal); + +// Handle button click +$button.click(() => { + $overlay.show(); + $modal.show(); + $('#quick-note-title').focus(); +}); + +// Handle save +$('#quick-note-save').click(async () => { + const title = $('#quick-note-title').val() || 'Quick Note'; + const content = $('#quick-note-content').val() || ''; + const type = $('#quick-note-type').val(); + + let finalContent = content; + + // Format based on type + if (type === 'task') { + finalContent = ` +

📋 ${title}

+
    +
  • [ ] ${content}
  • +
+ `; + } else if (type === 'code') { + finalContent = `// ${title}\n${content}`; + } else { + finalContent = `

${title}

${content}

`; + } + + // Get current note or use inbox + const currentNote = api.getActiveContextNote(); + const parentNoteId = currentNote ? currentNote.noteId : (await api.getDayNote()).noteId; + + // Create note + const { note } = await api.runOnBackend(async (parentId, noteTitle, noteContent, noteType) => { + const parent = await api.getNote(parentId); + const newNote = await api.createNote(parent, noteTitle, noteContent, noteType === 'code' ? 'code' : 'text'); + + if (noteType === 'task') { + await newNote.setLabel('task'); + await newNote.setLabel('created', api.dayjs().format()); + } + + return { note: newNote.getPojo() }; + }, [parentNoteId, title, finalContent, type]); + + api.showMessage(`Note "${title}" created!`); + + // Clear and close + $('#quick-note-title').val(''); + $('#quick-note-content').val(''); + $overlay.hide(); + $modal.hide(); + + // Navigate to new note + await api.activateNewNote(note.noteId); +}); + +// Handle cancel +$('#quick-note-cancel, #quick-note-overlay').click(() => { + $overlay.hide(); + $modal.hide(); +}); + +// Keyboard shortcuts +$(document).keydown((e) => { + // Ctrl+Shift+N to open quick note + if (e.ctrlKey && e.shiftKey && e.key === 'N') { + e.preventDefault(); + $button.click(); + } + + // Escape to close + if (e.key === 'Escape' && $modal.is(':visible')) { + $overlay.hide(); + $modal.hide(); + } +}); +``` + +### 7\. Note Graph Visualizer + +Create an interactive graph of note relationships: + +```javascript +// Load D3.js +await api.requireLibrary('d3'); + +// Create container +const $container = $(` +
+`); + +// Add to current note +const $noteDetail = $(`.note-detail-code`); +$noteDetail.empty().append($container); + +// Get note data +const graphData = await api.runOnBackend(async () => { + const currentNote = api.getActiveContextNote(); + const maxDepth = 3; + const nodes = []; + const links = []; + const visited = new Set(); + + async function traverse(note, depth = 0) { + if (!note || depth > maxDepth || visited.has(note.noteId)) { + return; + } + + visited.add(note.noteId); + + nodes.push({ + id: note.noteId, + title: note.title, + type: note.type, + depth: depth + }); + + // Get children + const children = await note.getChildNotes(); + for (const child of children) { + links.push({ + source: note.noteId, + target: child.noteId, + type: 'child' + }); + await traverse(child, depth + 1); + } + + // Get relations + const relations = await note.getRelations(); + for (const relation of relations) { + const targetNote = await relation.getTargetNote(); + if (targetNote) { + links.push({ + source: note.noteId, + target: targetNote.noteId, + type: 'relation', + name: relation.name + }); + + if (!visited.has(targetNote.noteId)) { + nodes.push({ + id: targetNote.noteId, + title: targetNote.title, + type: targetNote.type, + depth: depth + 1 + }); + visited.add(targetNote.noteId); + } + } + } + } + + await traverse(currentNote); + + return { nodes, links }; +}); + +// Create D3 visualization +const width = $container.width(); +const height = $container.height(); + +const svg = d3.select('#note-graph') + .append('svg') + .attr('width', width) + .attr('height', height); + +// Create force simulation +const simulation = d3.forceSimulation(graphData.nodes) + .force('link', d3.forceLink(graphData.links).id(d => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(width / 2, height / 2)); + +// Create links +const link = svg.append('g') + .selectAll('line') + .data(graphData.links) + .enter().append('line') + .style('stroke', d => d.type === 'child' ? '#999' : '#f00') + .style('stroke-opacity', 0.6) + .style('stroke-width', d => d.type === 'child' ? 2 : 1); + +// Create nodes +const node = svg.append('g') + .selectAll('circle') + .data(graphData.nodes) + .enter().append('circle') + .attr('r', d => 10 - d.depth * 2) + .style('fill', d => { + const colors = { + text: '#4CAF50', + code: '#2196F3', + file: '#FF9800', + image: '#9C27B0' + }; + return colors[d.type] || '#666'; + }) + .call(d3.drag() + .on('start', dragstarted) + .on('drag', dragged) + .on('end', dragended)); + +// Add labels +const label = svg.append('g') + .selectAll('text') + .data(graphData.nodes) + .enter().append('text') + .text(d => d.title) + .style('font-size', '12px') + .style('fill', '#333'); + +// Add tooltips +node.append('title') + .text(d => d.title); + +// Update positions on tick +simulation.on('tick', () => { + link + .attr('x1', d => d.source.x) + .attr('y1', d => d.source.y) + .attr('x2', d => d.target.x) + .attr('y2', d => d.target.y); + + node + .attr('cx', d => d.x) + .attr('cy', d => d.y); + + label + .attr('x', d => d.x + 12) + .attr('y', d => d.y + 4); +}); + +// Drag functions +function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; +} + +function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; +} + +function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; +} + +// Handle node clicks +node.on('click', async (event, d) => { + await api.activateNote(d.id); +}); +``` + +### 8\. Markdown Preview Toggle + +Add live markdown preview for notes: + +```javascript +// Create preview pane +const $previewPane = $(` +
+
+
+`); + +// Create toggle button +const $toggleBtn = $(` + +`); + +// Add to note detail +$('.note-detail-text').css('position', 'relative').append($previewPane, $toggleBtn); + +let previewVisible = false; +let updateTimeout; + +// Load markdown library +await api.requireLibrary('markdown-it'); +const md = window.markdownit({ + html: true, + linkify: true, + typographer: true, + breaks: true +}); + +// Toggle preview +$toggleBtn.click(() => { + previewVisible = !previewVisible; + + if (previewVisible) { + $previewPane.show(); + $('.note-detail-text .note-detail-editable').css('width', '50%'); + $toggleBtn.html(' Hide'); + updatePreview(); + } else { + $previewPane.hide(); + $('.note-detail-text .note-detail-editable').css('width', '100%'); + $toggleBtn.html(' Preview'); + } +}); + +// Update preview function +async function updatePreview() { + if (!previewVisible) return; + + const content = await api.getActiveContextTextEditor().getContent(); + + // Convert HTML to markdown first (simplified) + let markdown = content + .replace(/]*>(.*?)<\/h1>/g, '# $1\n') + .replace(/]*>(.*?)<\/h2>/g, '## $1\n') + .replace(/]*>(.*?)<\/h3>/g, '### $1\n') + .replace(/]*>(.*?)<\/p>/g, '$1\n\n') + .replace(/]*>(.*?)<\/strong>/g, '**$1**') + .replace(/]*>(.*?)<\/b>/g, '**$1**') + .replace(/]*>(.*?)<\/em>/g, '*$1*') + .replace(/]*>(.*?)<\/i>/g, '*$1*') + .replace(/]*>(.*?)<\/code>/g, '`$1`') + .replace(/]*>/g, '') + .replace(/<\/ul>/g, '\n') + .replace(/]*>(.*?)<\/li>/g, '- $1\n') + .replace(/]*>/g, '') + .replace(/<\/ol>/g, '\n') + .replace(/]*>(.*?)<\/li>/g, '1. $1\n') + .replace(/]*>(.*?)<\/a>/g, '[$2]($1)') + .replace(/]*src="api/images/Z7VzyCVBZwf1/([^"]*)"[^>]*alt="([^"]*)"[^>]*>/g, '![$2]($1)') + .replace(/]*>/g, '\n') + .replace(/<[^>]+>/g, ''); // Remove remaining HTML tags + + // Render markdown + const html = md.render(markdown); + + $('#preview-content').html(html); + + // Syntax highlight code blocks + $('#preview-content pre code').each(function() { + if (window.hljs) { + window.hljs.highlightElement(this); + } + }); +} + +// Auto-update preview on content change +api.bindGlobalShortcut('mod+s', async () => { + if (previewVisible) { + clearTimeout(updateTimeout); + updateTimeout = setTimeout(updatePreview, 500); + } +}); + +// Update on note change +api.onActiveContextNoteChange(async () => { + if (previewVisible) { + updatePreview(); + } +}); +``` + +## Common Patterns + +### 9\. Template System + +Create and apply templates to new notes: + +```javascript +// Backend script to manage templates + +async function createFromTemplate(templateName, targetParentId, customData = {}) { + // Find template + const template = await api.getNoteWithLabel(`template:${templateName}`); + if (!template) { + throw new Error(`Template "${templateName}" not found`); + } + + // Get template content and metadata + const content = await template.getContent(); + const attributes = await template.getAttributes(); + + // Process template variables + let processedContent = content; + const variables = { + DATE: api.dayjs().format('YYYY-MM-DD'), + TIME: api.dayjs().format('HH:mm:ss'), + DATETIME: api.dayjs().format('YYYY-MM-DD HH:mm:ss'), + USER: api.getAppInfo().username || 'User', + ...customData + }; + + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + processedContent = processedContent.replace(regex, value); + } + + // Create new note + const parentNote = await api.getNote(targetParentId); + const title = customData.title || `${templateName} - ${variables.DATE}`; + const newNote = await api.createNote(parentNote, title, processedContent); + + // Copy attributes (except template label) + for (const attr of attributes) { + if (!attr.name.startsWith('template:')) { + if (attr.type === 'label') { + await newNote.setLabel(attr.name, attr.value); + } else if (attr.type === 'relation') { + await newNote.setRelation(attr.name, attr.value); + } + } + } + + return newNote; +} + +// Example: Meeting notes template +const meetingTemplate = ` +

Meeting Notes - {{DATE}}

+ + + + + + +
Date:{{DATE}}
Time:{{TIME}}
Attendees:{{ATTENDEES}}
Subject:{{SUBJECT}}
+ +

Agenda

+
    +
  • {{AGENDA_ITEM_1}}
  • +
  • {{AGENDA_ITEM_2}}
  • +
  • {{AGENDA_ITEM_3}}
  • +
+ +

Discussion

+

+ +

Action Items

+
    +
  • [ ]
  • +
+ +

Next Steps

+

+`; + +// Create template note if it doesn't exist +let templateNote = await api.getNoteWithLabel('template:meeting'); +if (!templateNote) { + templateNote = await api.createTextNote('root', 'Meeting Template', meetingTemplate); + await templateNote.setLabel('template:meeting'); + await templateNote.setLabel('hideFromTree'); // Hide template from tree +} + +// Use template +const meeting = await createFromTemplate('meeting', 'root', { + title: 'Team Standup', + ATTENDEES: 'John, Jane, Bob', + SUBJECT: 'Weekly Status Update', + AGENDA_ITEM_1: 'Review last week\'s tasks', + AGENDA_ITEM_2: 'Current blockers', + AGENDA_ITEM_3: 'Next week\'s priorities' +}); + +api.log(`Created meeting note: ${meeting.title}`); +``` + +### 10\. Hierarchical Tag System + +Implement hierarchical tags with inheritance: + +```javascript +class HierarchicalTags { + constructor() { + this.tagHierarchy = {}; + } + + async buildTagHierarchy() { + // Find all tag definition notes + const tagNotes = await api.searchForNotes('#tagDef'); + + for (const note of tagNotes) { + const tagName = await note.getLabel('tagName'); + const parentTag = await note.getLabel('parentTag'); + + if (tagName) { + this.tagHierarchy[tagName.value] = { + noteId: note.noteId, + parent: parentTag ? parentTag.value : null, + children: [] + }; + } + } + + // Build children arrays + for (const [tag, data] of Object.entries(this.tagHierarchy)) { + if (data.parent && this.tagHierarchy[data.parent]) { + this.tagHierarchy[data.parent].children.push(tag); + } + } + + return this.tagHierarchy; + } + + async applyHierarchicalTag(noteId, tagName) { + const note = await api.getNote(noteId); + + // Apply the tag + await note.setLabel(tagName); + + // Apply all parent tags + let currentTag = tagName; + while (this.tagHierarchy[currentTag] && this.tagHierarchy[currentTag].parent) { + const parentTag = this.tagHierarchy[currentTag].parent; + await note.setLabel(parentTag); + currentTag = parentTag; + } + } + + async getNotesWithTagHierarchy(tagName) { + // Get all child tags + const allTags = [tagName]; + const queue = [tagName]; + + while (queue.length > 0) { + const current = queue.shift(); + if (this.tagHierarchy[current]) { + for (const child of this.tagHierarchy[current].children) { + allTags.push(child); + queue.push(child); + } + } + } + + // Search for notes with any of these tags + const searchQuery = allTags.map(t => `#${t}`).join(' OR '); + return await api.searchForNotes(searchQuery); + } + + async createTagReport() { + await this.buildTagHierarchy(); + + let report = '

Tag Hierarchy Report

\n'; + + // Build tree visualization + const renderTree = (tag, level = 0) => { + const indent = ' '.repeat(level * 4); + let html = `${indent}• ${tag}`; + + const notes = api.searchForNotes(`#${tag}`); + html += ` (${notes.length} notes)
\n`; + + if (this.tagHierarchy[tag] && this.tagHierarchy[tag].children.length > 0) { + for (const child of this.tagHierarchy[tag].children) { + html += renderTree(child, level + 1); + } + } + + return html; + }; + + // Find root tags (no parent) + const rootTags = Object.keys(this.tagHierarchy) + .filter(tag => !this.tagHierarchy[tag].parent); + + for (const rootTag of rootTags) { + report += renderTree(rootTag); + } + + // Create or update report note + let reportNote = await api.getNoteWithLabel('tagHierarchyReport'); + if (!reportNote) { + reportNote = await api.createTextNote('root', 'Tag Hierarchy Report', ''); + await reportNote.setLabel('tagHierarchyReport'); + } + + await reportNote.setContent(report); + + return report; + } +} + +// Usage +const tagSystem = new HierarchicalTags(); + +// Define tag hierarchy +const createTagDefinition = async (tagName, parentTag = null) => { + let tagDef = await api.getNoteWithLabel(`tagDef:${tagName}`); + if (!tagDef) { + tagDef = await api.createTextNote('root', `Tag: ${tagName}`, `Tag definition for ${tagName}`); + await tagDef.setLabel('tagDef'); + await tagDef.setLabel(`tagDef:${tagName}`); + await tagDef.setLabel('tagName', tagName); + if (parentTag) { + await tagDef.setLabel('parentTag', parentTag); + } + } + return tagDef; +}; + +// Create tag hierarchy +await createTagDefinition('project'); +await createTagDefinition('work', 'project'); +await createTagDefinition('personal', 'project'); +await createTagDefinition('development', 'work'); +await createTagDefinition('documentation', 'work'); + +// Apply hierarchical tag +await tagSystem.buildTagHierarchy(); +await tagSystem.applyHierarchicalTag('someNoteId', 'documentation'); +// This will also apply 'work' and 'project' tags + +// Get all notes in hierarchy +const projectNotes = await tagSystem.getNotesWithTagHierarchy('project'); +// Returns notes tagged with 'project', 'work', 'personal', 'development', or 'documentation' + +// Generate report +await tagSystem.createTagReport(); +``` + +## Integration with External Services + +### 11\. GitHub Integration + +Sync GitHub issues with notes: + +```javascript +// Requires axios library +const axios = require('axios'); + +class GitHubSync { + constructor(token, repo) { + this.token = token; + this.repo = repo; // format: "owner/repo" + this.apiBase = 'https://api.github.com'; + } + + async getIssues(state = 'open') { + const response = await axios.get(`${this.apiBase}/repos/${this.repo}/issues`, { + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' + }, + params: { state } + }); + + return response.data; + } + + async syncIssuesToNotes() { + // Get or create GitHub folder + let githubFolder = await api.getNoteWithLabel('githubSync'); + if (!githubFolder) { + githubFolder = await api.createTextNote('root', 'GitHub Issues', ''); + await githubFolder.setLabel('githubSync'); + } + + const issues = await this.getIssues(); + const syncedNotes = []; + + for (const issue of issues) { + // Check if issue note already exists + let issueNote = await api.getNoteWithLabel(`github:issue:${issue.number}`); + + const content = ` +

${issue.title}

+ + + + + + + + +
Issue #${issue.number}
State${issue.state}
Author${issue.user.login}
Created${api.dayjs(issue.created_at).format('YYYY-MM-DD HH:mm')}
Updated${api.dayjs(issue.updated_at).format('YYYY-MM-DD HH:mm')}
Labels${issue.labels.map(l => l.name).join(', ')}
+ +

Description

+
+ ${issue.body || 'No description'} +
+ +

Links

+
+ `; + + if (!issueNote) { + // Create new note + issueNote = await api.createNote( + githubFolder, + `#${issue.number}: ${issue.title}`, + content + ); + await issueNote.setLabel(`github:issue:${issue.number}`); + } else { + // Update existing note + await issueNote.setContent(content); + } + + // Set labels based on issue state and labels + await issueNote.setLabel('githubIssue'); + await issueNote.setLabel('state', issue.state); + + for (const label of issue.labels) { + await issueNote.setLabel(`gh:${label.name}`); + } + + syncedNotes.push({ + noteId: issueNote.noteId, + issueNumber: issue.number, + title: issue.title + }); + } + + api.log(`Synced ${syncedNotes.length} GitHub issues`); + return syncedNotes; + } + + async createIssueFromNote(noteId) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Extract plain text from HTML + const plainText = content.replace(/<[^>]*>/g, ''); + + const response = await axios.post( + `${this.apiBase}/repos/${this.repo}/issues`, + { + title: note.title, + body: plainText, + labels: ['from-trilium'] + }, + { + headers: { + 'Authorization': `token ${this.token}`, + 'Accept': 'application/vnd.github.v3+json' + } + } + ); + + // Link note to issue + await note.setLabel(`github:issue:${response.data.number}`); + await note.setLabel('githubIssue'); + + return response.data; + } +} + +// Usage +const github = new GitHubSync( + process.env.GITHUB_TOKEN || 'your-token', + 'your-org/your-repo' +); + +// Sync issues to notes +const synced = await github.syncIssuesToNotes(); + +// Create issue from current note +// const issue = await github.createIssueFromNote('currentNoteId'); +``` + +### 12\. Email Integration + +Send notes via email: + +```javascript +const nodemailer = require('nodemailer'); + +class EmailIntegration { + constructor(config) { + this.transporter = nodemailer.createTransporter({ + host: config.host || 'smtp.gmail.com', + port: config.port || 587, + secure: false, + auth: { + user: config.user, + pass: config.pass + } + }); + } + + async sendNoteAsEmail(noteId, to, options = {}) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Get attachments + const attachments = await note.getAttachments(); + const mailAttachments = []; + + for (const attachment of attachments) { + const blob = await attachment.getBlob(); + mailAttachments.push({ + filename: attachment.title, + content: blob.content, + contentType: attachment.mime + }); + } + + // Convert note content to email-friendly HTML + const emailHtml = ` + + + + + + + ${content} +
+

+ Sent from Trilium Notes on ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')} +

+ + + `; + + const mailOptions = { + from: options.from || this.transporter.options.auth.user, + to: to, + subject: options.subject || note.title, + html: emailHtml, + attachments: mailAttachments + }; + + const info = await this.transporter.sendMail(mailOptions); + + // Log email send + await note.setLabel('emailSent', api.dayjs().format()); + await note.setLabel('emailRecipient', to); + + api.log(`Email sent: ${info.messageId}`); + + return info; + } + + async createEmailCampaign(templateNoteId, recipientListNoteId) { + const template = await api.getNote(templateNoteId); + const recipientNote = await api.getNote(recipientListNoteId); + const recipientContent = await recipientNote.getContent(); + + // Parse recipient list (assume one email per line) + const recipients = recipientContent + .split('\n') + .map(line => line.trim()) + .filter(line => line && line.includes('@')); + + const results = []; + + for (const recipient of recipients) { + try { + const result = await this.sendNoteAsEmail( + templateNoteId, + recipient, + { + subject: template.title + } + ); + + results.push({ + recipient, + success: true, + messageId: result.messageId + }); + + // Add delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + results.push({ + recipient, + success: false, + error: error.message + }); + } + } + + // Create campaign report + const reportNote = await api.createTextNote( + 'root', + `Email Campaign Report - ${api.dayjs().format('YYYY-MM-DD')}`, + ` +

Email Campaign Report

+

Template: ${template.title}

+

Sent: ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')}

+

Total Recipients: ${recipients.length}

+

Successful: ${results.filter(r => r.success).length}

+

Failed: ${results.filter(r => !r.success).length}

+ +

Results

+ + + ${results.map(r => ` + + + + + + `).join('')} +
RecipientStatusDetails
${r.recipient}${r.success ? '✅ Sent' : '❌ Failed'}${r.success ? r.messageId : r.error}
+ ` + ); + + await reportNote.setLabel('emailCampaignReport'); + + return results; + } +} + +// Usage +const email = new EmailIntegration({ + host: 'smtp.gmail.com', + port: 587, + user: 'your-email@gmail.com', + pass: 'your-app-password' +}); + +// Send single note +// await email.sendNoteAsEmail('noteId', 'recipient@example.com'); + +// Send campaign +// await email.createEmailCampaign('templateNoteId', 'recipientListNoteId'); +``` + +## Best Practices + +### Error Handling + +Always wrap scripts in try-catch blocks: + +```javascript +async function safeScriptExecution() { + try { + // Your script code here + const result = await riskyOperation(); + + return { + success: true, + data: result + }; + } catch (error) { + api.log(`Error in script: ${error.message}`, 'error'); + + // Create error report note + const errorNote = await api.createTextNote( + 'root', + `Script Error - ${api.dayjs().format('YYYY-MM-DD HH:mm:ss')}`, + ` +

Script Error

+

Error: ${error.message}

+

Stack:

+
${error.stack}
+

Script: ${api.currentNote.title}

+ ` + ); + + await errorNote.setLabel('scriptError'); + + return { + success: false, + error: error.message + }; + } +} + +return await safeScriptExecution(); +``` + +### Performance Optimization + +Use batch operations and caching: + +```javascript +class OptimizedNoteProcessor { + constructor() { + this.cache = new Map(); + } + + async processNotes(noteIds) { + // Batch fetch notes + const notes = await Promise.all( + noteIds.map(id => this.getCachedNote(id)) + ); + + // Process in chunks to avoid memory issues + const chunkSize = 100; + const results = []; + + for (let i = 0; i < notes.length; i += chunkSize) { + const chunk = notes.slice(i, i + chunkSize); + const chunkResults = await Promise.all( + chunk.map(note => this.processNote(note)) + ); + results.push(...chunkResults); + + // Allow other operations + await new Promise(resolve => setTimeout(resolve, 10)); + } + + return results; + } + + async getCachedNote(noteId) { + if (!this.cache.has(noteId)) { + const note = await api.getNote(noteId); + this.cache.set(noteId, note); + } + return this.cache.get(noteId); + } + + async processNote(note) { + // Process individual note + return { + noteId: note.noteId, + processed: true + }; + } +} +``` + +### Script Organization + +Organize complex scripts with modules: + +```javascript +// Create a utility module note +const utilsNote = await api.createCodeNote('root', 'Script Utils', ` + module.exports = { + formatDate: (date) => api.dayjs(date).format('YYYY-MM-DD'), + + sanitizeHtml: (html) => { + return html + .replace(/]*>.*?<\/script>/gi, '') + .replace(/on\w+="[^"]*"/gi, ''); + }, + + async createBackup(name) { + await api.backupDatabase(name); + api.log(\`Backup created: \\${name}\`); + } + }; +`, 'js'); + +await utilsNote.setLabel('scriptModule'); +await utilsNote.setLabel('moduleName', 'utils'); + +// Use in another script +const utils = await api.requireModule('utils'); +const formattedDate = utils.formatDate(new Date()); +``` + +### Testing Scripts + +Create test suites for your scripts: + +```javascript +class ScriptTester { + constructor(scriptName) { + this.scriptName = scriptName; + this.tests = []; + this.results = []; + } + + test(description, testFn) { + this.tests.push({ description, testFn }); + } + + async run() { + api.log(`Running tests for ${this.scriptName}`); + + for (const test of this.tests) { + try { + await test.testFn(); + this.results.push({ + description: test.description, + passed: true + }); + api.log(`✅ ${test.description}`); + } catch (error) { + this.results.push({ + description: test.description, + passed: false, + error: error.message + }); + api.log(`❌ ${test.description}: ${error.message}`); + } + } + + return this.generateReport(); + } + + generateReport() { + const passed = this.results.filter(r => r.passed).length; + const failed = this.results.filter(r => !r.passed).length; + + return { + script: this.scriptName, + total: this.results.length, + passed, + failed, + results: this.results + }; + } + + assert(condition, message) { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } + } + + assertEquals(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } + } +} + +// Example test suite +const tester = new ScriptTester('Note Utils'); + +tester.test('Create note', async () => { + const note = await api.createTextNote('root', 'Test Note', 'Content'); + tester.assert(note !== null, 'Note should be created'); + tester.assertEquals(note.title, 'Test Note', 'Title should match'); + + // Clean up + await note.delete(); +}); + +tester.test('Search notes', async () => { + const results = await api.searchForNotes('test'); + tester.assert(Array.isArray(results), 'Results should be an array'); +}); + +const report = await tester.run(); +return report; +``` + +## Conclusion + +The Script API provides powerful capabilities for automating and extending Trilium Notes. Key takeaways: + +1. **Use Backend Scripts** for data processing, automation, and integrations +2. **Use Frontend Scripts** for UI enhancements and user interactions +3. **Always handle errors** gracefully and provide meaningful feedback +4. **Optimize performance** with caching and batch operations +5. **Organize complex scripts** into modules for reusability +6. **Test your scripts** to ensure reliability + +For more information: + +* [Backend Script API Reference](https://triliumnext.github.io/Docs/api/Backend_Script_API.html) +* [Frontend Script API Reference](https://triliumnext.github.io/Docs/api/Frontend_Script_API.html) +* [Custom Widget Development](#root/CXtjbrjXfIlk) \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md b/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md new file mode 100644 index 00000000000..58f5b84a71d --- /dev/null +++ b/docs/Developer Guide/Developer Guide/API Documentation/WebSocket API.md @@ -0,0 +1,1795 @@ +# WebSocket API +## WebSocket API Documentation + +## Table of Contents + +1. [Introduction](#introduction) +2. [Connection Setup](#connection-setup) +3. [Authentication](#authentication) +4. [Message Format](#message-format) +5. [Event Types](#event-types) +6. [Real-time Synchronization](#real-time-synchronization) +7. [Custom Event Broadcasting](#custom-event-broadcasting) +8. [Client Implementation Examples](#client-implementation-examples) +9. [Debugging WebSocket Connections](#debugging-websocket-connections) +10. [Best Practices](#best-practices) +11. [Error Handling](#error-handling) +12. [Performance Optimization](#performance-optimization) + +## Introduction + +The Trilium WebSocket API provides real-time bidirectional communication between the server and clients. It's primarily used for: + +* **Real-time synchronization** of note changes across multiple clients +* **Live collaboration** features +* **Push notifications** for events +* **Streaming updates** for long-running operations +* **Frontend script execution** from backend + +### Key Features + +* Automatic reconnection with exponential backoff +* Message queuing during disconnection +* Event-based architecture +* Support for custom event types +* Built-in heartbeat/ping mechanism + +### WebSocket URL + +``` +ws://localhost:8080 // Local development +wss://your-server.com // Production with SSL +``` + +## Connection Setup + +### Basic Connection + +```javascript +// JavaScript - Basic WebSocket connection +const ws = new WebSocket('ws://localhost:8080'); + +ws.onopen = (event) => { + console.log('Connected to Trilium WebSocket'); +}; + +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + console.log('Received:', message); +}; + +ws.onerror = (error) => { + console.error('WebSocket error:', error); +}; + +ws.onclose = (event) => { + console.log('Disconnected from WebSocket'); +}; +``` + +### Advanced Connection Manager + +```javascript +class TriliumWebSocketManager { + constructor(url, options = {}) { + this.url = url; + this.options = { + reconnectInterval: 5000, + maxReconnectInterval: 30000, + reconnectDecay: 1.5, + timeoutInterval: 2000, + maxReconnectAttempts: null, + ...options + }; + + this.ws = null; + this.forcedClose = false; + this.reconnectAttempts = 0; + this.messageQueue = []; + this.eventHandlers = new Map(); + this.reconnectTimer = null; + this.pingTimer = null; + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = (event) => { + console.log('WebSocket connected'); + this.onOpen(event); + }; + + this.ws.onmessage = (event) => { + this.onMessage(event); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.onError(error); + }; + + this.ws.onclose = (event) => { + console.log('WebSocket closed'); + this.onClose(event); + }; + } + + onOpen(event) { + this.reconnectAttempts = 0; + + // Send queued messages + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + this.send(message); + } + + // Start ping timer + this.startPing(); + + // Emit open event + this.emit('open', event); + } + + onMessage(event) { + try { + const message = JSON.parse(event.data); + + // Handle different message types + if (message.type === 'pong') { + this.handlePong(message); + } else { + this.emit('message', message); + + // Emit specific event type + if (message.type) { + this.emit(message.type, message.data || message); + } + } + } catch (error) { + console.error('Failed to parse message:', error); + } + } + + onError(error) { + this.emit('error', error); + } + + onClose(event) { + this.ws = null; + + if (!this.forcedClose) { + this.reconnect(); + } + + this.stopPing(); + this.emit('close', event); + } + + reconnect() { + if (this.options.maxReconnectAttempts && + this.reconnectAttempts >= this.options.maxReconnectAttempts) { + this.emit('max-reconnects'); + return; + } + + this.reconnectAttempts++; + + const timeout = Math.min( + this.options.reconnectInterval * Math.pow( + this.options.reconnectDecay, + this.reconnectAttempts - 1 + ), + this.options.maxReconnectInterval + ); + + console.log(`Reconnecting in ${timeout}ms (attempt ${this.reconnectAttempts})`); + + this.reconnectTimer = setTimeout(() => { + console.log('Reconnecting...'); + this.connect(); + }, timeout); + + this.emit('reconnecting', { + attempt: this.reconnectAttempts, + timeout + }); + } + + send(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + const message = typeof data === 'string' ? data : JSON.stringify(data); + this.ws.send(message); + } else { + // Queue message for later + this.messageQueue.push(data); + } + } + + startPing() { + this.pingTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.send({ type: 'ping', timestamp: Date.now() }); + } + }, 30000); // Ping every 30 seconds + } + + stopPing() { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + handlePong(message) { + const latency = Date.now() - message.timestamp; + this.emit('latency', latency); + } + + on(event, handler) { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, []); + } + this.eventHandlers.get(event).push(handler); + } + + off(event, handler) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + const index = handlers.indexOf(handler); + if (index !== -1) { + handlers.splice(index, 1); + } + } + } + + emit(event, data) { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.forEach(handler => { + try { + handler(data); + } catch (error) { + console.error(`Error in event handler for ${event}:`, error); + } + }); + } + } + + close() { + this.forcedClose = true; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + } + + this.stopPing(); + } + + getState() { + if (!this.ws) { + return 'DISCONNECTED'; + } + + switch (this.ws.readyState) { + case WebSocket.CONNECTING: + return 'CONNECTING'; + case WebSocket.OPEN: + return 'CONNECTED'; + case WebSocket.CLOSING: + return 'CLOSING'; + case WebSocket.CLOSED: + return 'DISCONNECTED'; + default: + return 'UNKNOWN'; + } + } +} +``` + +## Authentication + +WebSocket connections inherit authentication from the HTTP session or require token-based auth. + +### Session-Based Authentication + +```javascript +// Session auth (cookies must be included) +const ws = new WebSocket('ws://localhost:8080', { + headers: { + 'Cookie': document.cookie // Include session cookie + } +}); +``` + +### Token-Based Authentication + +```javascript +// Send auth token after connection +class AuthenticatedWebSocket { + constructor(url, token) { + this.url = url; + this.token = token; + this.authenticated = false; + } + + connect() { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + // Send authentication message + this.send({ + type: 'auth', + token: this.token + }); + }; + + this.ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'auth-success') { + this.authenticated = true; + this.onAuthenticated(); + } else if (message.type === 'auth-error') { + this.onAuthError(message.error); + } else if (this.authenticated) { + this.handleMessage(message); + } + }; + } + + send(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + onAuthenticated() { + console.log('WebSocket authenticated'); + } + + onAuthError(error) { + console.error('Authentication failed:', error); + } + + handleMessage(message) { + // Handle authenticated messages + } +} +``` + +## Message Format + +### Standard Message Structure + +```typescript +interface WebSocketMessage { + type: string; // Message type identifier + data?: any; // Message payload + timestamp?: number; // Unix timestamp + id?: string; // Message ID for tracking + error?: string; // Error message if applicable +} +``` + +### Common Message Types + +```javascript +// Incoming messages from server +const incomingMessages = { + // Synchronization + 'sync': { + type: 'sync', + data: { + entityChanges: [], + lastSyncedPush: 12345 + } + }, + + // Entity changes + 'entity-changes': { + type: 'entity-changes', + data: [ + { + entityName: 'notes', + entityId: 'noteId123', + action: 'update', + entity: { /* note data */ } + } + ] + }, + + // Note events + 'note-created': { + type: 'note-created', + data: { + noteId: 'newNoteId', + title: 'New Note', + parentNoteId: 'parentId' + } + }, + + 'note-updated': { + type: 'note-updated', + data: { + noteId: 'noteId123', + changes: { title: 'Updated Title' } + } + }, + + 'note-deleted': { + type: 'note-deleted', + data: { + noteId: 'deletedNoteId' + } + }, + + // Tree structure changes + 'refresh-tree': { + type: 'refresh-tree', + data: { + noteId: 'affectedNoteId' + } + }, + + // Script execution + 'frontend-script': { + type: 'frontend-script', + data: { + script: 'console.log("Hello from backend")', + params: { key: 'value' } + } + }, + + // Progress updates + 'progress-update': { + type: 'progress-update', + data: { + taskId: 'task123', + progress: 75, + message: 'Processing...' + } + }, + + // LLM streaming + 'llm-stream': { + type: 'llm-stream', + chatNoteId: 'chatNote123', + content: 'Streaming response...', + done: false + } +}; + +// Outgoing messages to server +const outgoingMessages = { + // Keep-alive ping + 'ping': { + type: 'ping', + timestamp: Date.now() + }, + + // Client logging + 'log-error': { + type: 'log-error', + error: 'Error message', + stack: 'Stack trace' + }, + + 'log-info': { + type: 'log-info', + info: 'Information message' + }, + + // Custom events + 'custom-event': { + type: 'custom-event', + data: { + eventName: 'user-action', + payload: { /* custom data */ } + } + } +}; +``` + +## Event Types + +### System Events + +```javascript +class TriliumEventHandler { + constructor(wsManager) { + this.wsManager = wsManager; + this.setupEventHandlers(); + } + + setupEventHandlers() { + // Connection events + this.wsManager.on('open', () => { + console.log('Connected to Trilium'); + this.onConnect(); + }); + + this.wsManager.on('close', () => { + console.log('Disconnected from Trilium'); + this.onDisconnect(); + }); + + this.wsManager.on('error', (error) => { + console.error('WebSocket error:', error); + this.onError(error); + }); + + this.wsManager.on('reconnecting', (info) => { + console.log(`Reconnecting... Attempt ${info.attempt}`); + this.onReconnecting(info); + }); + + // Trilium-specific events + this.wsManager.on('sync', (data) => { + this.handleSync(data); + }); + + this.wsManager.on('entity-changes', (changes) => { + this.handleEntityChanges(changes); + }); + + this.wsManager.on('note-created', (note) => { + this.handleNoteCreated(note); + }); + + this.wsManager.on('note-updated', (update) => { + this.handleNoteUpdated(update); + }); + + this.wsManager.on('note-deleted', (deletion) => { + this.handleNoteDeleted(deletion); + }); + + this.wsManager.on('refresh-tree', (data) => { + this.handleTreeRefresh(data); + }); + } + + onConnect() { + // Update UI to show connected status + this.updateConnectionStatus('connected'); + } + + onDisconnect() { + // Update UI to show disconnected status + this.updateConnectionStatus('disconnected'); + } + + onError(error) { + // Handle error + this.showError(error.message); + } + + onReconnecting(info) { + // Show reconnection status + this.updateConnectionStatus(`reconnecting (${info.attempt})`); + } + + handleSync(data) { + console.log('Sync data received:', data); + // Process synchronization data + if (data.entityChanges && data.entityChanges.length > 0) { + this.processSyncChanges(data.entityChanges); + } + } + + handleEntityChanges(changes) { + console.log('Entity changes:', changes); + + changes.forEach(change => { + switch (change.entityName) { + case 'notes': + this.processNoteChange(change); + break; + case 'branches': + this.processBranchChange(change); + break; + case 'attributes': + this.processAttributeChange(change); + break; + } + }); + } + + handleNoteCreated(note) { + console.log('Note created:', note); + // Update local cache + this.addNoteToCache(note); + // Update UI + this.addNoteToTree(note); + } + + handleNoteUpdated(update) { + console.log('Note updated:', update); + // Update local cache + this.updateNoteInCache(update.noteId, update.changes); + // Update UI if note is visible + if (this.isNoteVisible(update.noteId)) { + this.refreshNoteDisplay(update.noteId); + } + } + + handleNoteDeleted(deletion) { + console.log('Note deleted:', deletion); + // Remove from cache + this.removeNoteFromCache(deletion.noteId); + // Update UI + this.removeNoteFromTree(deletion.noteId); + } + + handleTreeRefresh(data) { + console.log('Tree refresh requested:', data); + // Refresh tree structure + this.refreshTreeBranch(data.noteId); + } + + // Placeholder methods for UI updates + updateConnectionStatus(status) { /* ... */ } + showError(message) { /* ... */ } + processSyncChanges(changes) { /* ... */ } + processNoteChange(change) { /* ... */ } + processBranchChange(change) { /* ... */ } + processAttributeChange(change) { /* ... */ } + addNoteToCache(note) { /* ... */ } + addNoteToTree(note) { /* ... */ } + updateNoteInCache(noteId, changes) { /* ... */ } + isNoteVisible(noteId) { /* ... */ } + refreshNoteDisplay(noteId) { /* ... */ } + removeNoteFromCache(noteId) { /* ... */ } + removeNoteFromTree(noteId) { /* ... */ } + refreshTreeBranch(noteId) { /* ... */ } +} +``` + +## Real-time Synchronization + +### Sync Protocol Implementation + +```javascript +class TriliumSyncManager { + constructor(wsManager) { + this.wsManager = wsManager; + this.lastSyncedPush = null; + this.pendingChanges = []; + this.syncInProgress = false; + + this.setupSyncHandlers(); + } + + setupSyncHandlers() { + this.wsManager.on('sync', (data) => { + this.handleIncomingSync(data); + }); + + this.wsManager.on('sync-complete', (data) => { + this.onSyncComplete(data); + }); + + this.wsManager.on('sync-error', (error) => { + this.onSyncError(error); + }); + } + + async handleIncomingSync(syncData) { + console.log('Processing sync data:', syncData); + + this.syncInProgress = true; + + try { + // Process entity changes in order + for (const change of syncData.entityChanges) { + await this.processEntityChange(change); + } + + // Update sync position + this.lastSyncedPush = syncData.lastSyncedPush; + + // Send acknowledgment + this.wsManager.send({ + type: 'sync-ack', + lastSyncedPush: this.lastSyncedPush + }); + + } catch (error) { + console.error('Sync processing error:', error); + this.wsManager.send({ + type: 'sync-error', + error: error.message, + lastSyncedPush: this.lastSyncedPush + }); + } finally { + this.syncInProgress = false; + this.processPendingChanges(); + } + } + + async processEntityChange(change) { + const { entityName, entityId, action, entity } = change; + + console.log(`Processing ${action} for ${entityName}:${entityId}`); + + switch (entityName) { + case 'notes': + await this.processNoteChange(action, entityId, entity); + break; + case 'branches': + await this.processBranchChange(action, entityId, entity); + break; + case 'attributes': + await this.processAttributeChange(action, entityId, entity); + break; + case 'note_contents': + await this.processContentChange(action, entityId, entity); + break; + } + } + + async processNoteChange(action, noteId, noteData) { + switch (action) { + case 'create': + await this.createNote(noteId, noteData); + break; + case 'update': + await this.updateNote(noteId, noteData); + break; + case 'delete': + await this.deleteNote(noteId); + break; + } + } + + async createNote(noteId, noteData) { + // Add to local database/cache + await localDB.notes.add({ + ...noteData, + noteId, + syncVersion: this.lastSyncedPush + }); + + // Emit event for UI update + this.emit('note-created', { noteId, noteData }); + } + + async updateNote(noteId, updates) { + // Update local database/cache + await localDB.notes.update(noteId, { + ...updates, + syncVersion: this.lastSyncedPush + }); + + // Emit event for UI update + this.emit('note-updated', { noteId, updates }); + } + + async deleteNote(noteId) { + // Remove from local database/cache + await localDB.notes.delete(noteId); + + // Emit event for UI update + this.emit('note-deleted', { noteId }); + } + + // Send local changes to server + async pushLocalChanges() { + if (this.syncInProgress) { + return; + } + + const localChanges = await this.getLocalChanges(); + + if (localChanges.length === 0) { + return; + } + + this.wsManager.send({ + type: 'push-changes', + changes: localChanges, + lastSyncedPull: this.lastSyncedPull + }); + } + + async getLocalChanges() { + // Get changes from local database that haven't been synced + const changes = await localDB.changes + .where('syncVersion') + .above(this.lastSyncedPush || 0) + .toArray(); + + return changes; + } + + processPendingChanges() { + if (this.pendingChanges.length > 0 && !this.syncInProgress) { + const changes = this.pendingChanges.splice(0); + this.handleIncomingSync({ entityChanges: changes }); + } + } + + emit(event, data) { + // Emit events to application + window.dispatchEvent(new CustomEvent(`trilium:${event}`, { detail: data })); + } +} +``` + +### Conflict Resolution + +```javascript +class ConflictResolver { + constructor(syncManager) { + this.syncManager = syncManager; + } + + async resolveConflict(localEntity, remoteEntity) { + // Compare timestamps + const localTime = new Date(localEntity.utcDateModified).getTime(); + const remoteTime = new Date(remoteEntity.utcDateModified).getTime(); + + if (localTime === remoteTime) { + // Same timestamp, compare content + return this.resolveByContent(localEntity, remoteEntity); + } + + // Default: last-write-wins + if (remoteTime > localTime) { + return { + winner: 'remote', + entity: remoteEntity, + backup: localEntity + }; + } else { + return { + winner: 'local', + entity: localEntity, + backup: remoteEntity + }; + } + } + + resolveByContent(localEntity, remoteEntity) { + // Create three-way merge if possible + const baseEntity = this.getBaseEntity(localEntity.entityId); + + if (baseEntity) { + return this.threeWayMerge(baseEntity, localEntity, remoteEntity); + } + + // Fall back to manual resolution + return this.promptUserResolution(localEntity, remoteEntity); + } + + threeWayMerge(base, local, remote) { + // Implement three-way merge logic + const merged = { ...base }; + + // Merge each property + for (const key in local) { + if (local[key] !== base[key] && remote[key] !== base[key]) { + // Both changed - conflict + if (local[key] === remote[key]) { + // Same change + merged[key] = local[key]; + } else { + // Different changes - need resolution + merged[key] = this.mergeProperty(key, base[key], local[key], remote[key]); + } + } else if (local[key] !== base[key]) { + // Only local changed + merged[key] = local[key]; + } else if (remote[key] !== base[key]) { + // Only remote changed + merged[key] = remote[key]; + } + } + + return { + winner: 'merged', + entity: merged, + localChanges: this.diff(base, local), + remoteChanges: this.diff(base, remote) + }; + } + + mergeProperty(key, base, local, remote) { + // Property-specific merge strategies + switch (key) { + case 'content': + // For content, try text merge + return this.mergeText(base, local, remote); + case 'attributes': + // For attributes, merge arrays + return this.mergeArrays(base, local, remote); + default: + // Default to remote for safety + return remote; + } + } + + async promptUserResolution(local, remote) { + // Show conflict resolution UI + const resolution = await this.showConflictDialog({ + local, + remote, + diff: this.diff(local, remote) + }); + + return resolution; + } + + diff(obj1, obj2) { + const changes = {}; + + for (const key in obj2) { + if (obj1[key] !== obj2[key]) { + changes[key] = { + old: obj1[key], + new: obj2[key] + }; + } + } + + return changes; + } +} +``` + +## Custom Event Broadcasting + +### Creating Custom Events + +```javascript +class CustomEventBroadcaster { + constructor(wsManager) { + this.wsManager = wsManager; + this.eventListeners = new Map(); + } + + // Broadcast event to all connected clients + broadcast(eventName, data) { + this.wsManager.send({ + type: 'custom-broadcast', + eventName, + data, + timestamp: Date.now() + }); + } + + // Send event to specific clients + sendToClients(clientIds, eventName, data) { + this.wsManager.send({ + type: 'targeted-broadcast', + targets: clientIds, + eventName, + data, + timestamp: Date.now() + }); + } + + // Subscribe to custom events + subscribe(eventName, handler) { + if (!this.eventListeners.has(eventName)) { + this.eventListeners.set(eventName, []); + } + + this.eventListeners.get(eventName).push(handler); + + // Register with server + this.wsManager.send({ + type: 'subscribe', + eventName + }); + } + + // Unsubscribe from events + unsubscribe(eventName, handler) { + const handlers = this.eventListeners.get(eventName); + if (handlers) { + const index = handlers.indexOf(handler); + if (index !== -1) { + handlers.splice(index, 1); + } + + if (handlers.length === 0) { + this.eventListeners.delete(eventName); + + // Unregister with server + this.wsManager.send({ + type: 'unsubscribe', + eventName + }); + } + } + } + + // Handle incoming custom events + handleCustomEvent(message) { + const { eventName, data } = message; + const handlers = this.eventListeners.get(eventName); + + if (handlers) { + handlers.forEach(handler => { + try { + handler(data); + } catch (error) { + console.error(`Error handling custom event ${eventName}:`, error); + } + }); + } + } +} + +// Usage example +const broadcaster = new CustomEventBroadcaster(wsManager); + +// Subscribe to custom events +broadcaster.subscribe('user-joined', (data) => { + console.log(`User ${data.username} joined`); +}); + +broadcaster.subscribe('collaborative-edit', (data) => { + console.log(`Edit on note ${data.noteId}: ${data.change}`); +}); + +// Broadcast custom event +broadcaster.broadcast('user-action', { + action: 'viewed-note', + noteId: 'abc123', + userId: 'user456' +}); +``` + +### Collaborative Features + +```javascript +class CollaborationManager { + constructor(wsManager, userId) { + this.wsManager = wsManager; + this.userId = userId; + this.activeSessions = new Map(); + this.cursorPositions = new Map(); + + this.setupCollaborationHandlers(); + } + + setupCollaborationHandlers() { + this.wsManager.on('collab-session-started', (data) => { + this.handleSessionStarted(data); + }); + + this.wsManager.on('collab-user-joined', (data) => { + this.handleUserJoined(data); + }); + + this.wsManager.on('collab-user-left', (data) => { + this.handleUserLeft(data); + }); + + this.wsManager.on('collab-cursor-update', (data) => { + this.handleCursorUpdate(data); + }); + + this.wsManager.on('collab-selection-update', (data) => { + this.handleSelectionUpdate(data); + }); + + this.wsManager.on('collab-content-change', (data) => { + this.handleContentChange(data); + }); + } + + startCollaborationSession(noteId) { + this.wsManager.send({ + type: 'start-collab-session', + noteId, + userId: this.userId + }); + + const session = { + noteId, + users: new Set([this.userId]), + startTime: Date.now() + }; + + this.activeSessions.set(noteId, session); + + return session; + } + + joinCollaborationSession(noteId) { + this.wsManager.send({ + type: 'join-collab-session', + noteId, + userId: this.userId + }); + } + + leaveCollaborationSession(noteId) { + this.wsManager.send({ + type: 'leave-collab-session', + noteId, + userId: this.userId + }); + + this.activeSessions.delete(noteId); + } + + sendCursorPosition(noteId, position) { + this.wsManager.send({ + type: 'collab-cursor-update', + noteId, + userId: this.userId, + position + }); + } + + sendSelectionUpdate(noteId, selection) { + this.wsManager.send({ + type: 'collab-selection-update', + noteId, + userId: this.userId, + selection + }); + } + + sendContentChange(noteId, change) { + this.wsManager.send({ + type: 'collab-content-change', + noteId, + userId: this.userId, + change + }); + } + + handleSessionStarted(data) { + const { noteId, users } = data; + + const session = { + noteId, + users: new Set(users), + startTime: Date.now() + }; + + this.activeSessions.set(noteId, session); + + // Update UI to show collaboration indicators + this.showCollaborationIndicator(noteId, users); + } + + handleUserJoined(data) { + const { noteId, userId, userInfo } = data; + const session = this.activeSessions.get(noteId); + + if (session) { + session.users.add(userId); + this.showUserJoinedNotification(userInfo); + } + } + + handleUserLeft(data) { + const { noteId, userId } = data; + const session = this.activeSessions.get(noteId); + + if (session) { + session.users.delete(userId); + this.removeUserCursor(userId); + } + } + + handleCursorUpdate(data) { + const { userId, position } = data; + + if (userId !== this.userId) { + this.cursorPositions.set(userId, position); + this.updateUserCursor(userId, position); + } + } + + handleSelectionUpdate(data) { + const { userId, selection } = data; + + if (userId !== this.userId) { + this.updateUserSelection(userId, selection); + } + } + + handleContentChange(data) { + const { noteId, userId, change } = data; + + if (userId !== this.userId) { + this.applyRemoteChange(noteId, change); + } + } + + // UI update methods (implement based on your UI framework) + showCollaborationIndicator(noteId, users) { /* ... */ } + showUserJoinedNotification(userInfo) { /* ... */ } + removeUserCursor(userId) { /* ... */ } + updateUserCursor(userId, position) { /* ... */ } + updateUserSelection(userId, selection) { /* ... */ } + applyRemoteChange(noteId, change) { /* ... */ } +} +``` + +## Client Implementation Examples + +### React Hook + +``` +// useWebSocket.js +import { useEffect, useRef, useState, useCallback } from 'react'; + +export function useTriliumWebSocket(url, options = {}) { + const [isConnected, setIsConnected] = useState(false); + const [lastMessage, setLastMessage] = useState(null); + const [error, setError] = useState(null); + + const wsManager = useRef(null); + const messageHandlers = useRef(new Map()); + + useEffect(() => { + wsManager.current = new TriliumWebSocketManager(url, options); + + wsManager.current.on('open', () => { + setIsConnected(true); + setError(null); + }); + + wsManager.current.on('close', () => { + setIsConnected(false); + }); + + wsManager.current.on('error', (err) => { + setError(err); + }); + + wsManager.current.on('message', (msg) => { + setLastMessage(msg); + + // Call registered handlers + const handler = messageHandlers.current.get(msg.type); + if (handler) { + handler(msg.data || msg); + } + }); + + wsManager.current.connect(); + + return () => { + wsManager.current.close(); + }; + }, [url]); + + const sendMessage = useCallback((message) => { + if (wsManager.current) { + wsManager.current.send(message); + } + }, []); + + const subscribe = useCallback((messageType, handler) => { + messageHandlers.current.set(messageType, handler); + + return () => { + messageHandlers.current.delete(messageType); + }; + }, []); + + return { + isConnected, + lastMessage, + error, + sendMessage, + subscribe + }; +} + +// Usage in React component +function TriliumNoteEditor({ noteId }) { + const { isConnected, sendMessage, subscribe } = useTriliumWebSocket( + 'ws://localhost:8080' + ); + + const [content, setContent] = useState(''); + + useEffect(() => { + // Subscribe to note updates + const unsubscribe = subscribe('note-updated', (data) => { + if (data.noteId === noteId) { + setContent(data.content); + } + }); + + return unsubscribe; + }, [noteId, subscribe]); + + const handleContentChange = (newContent) => { + setContent(newContent); + + // Send update via WebSocket + sendMessage({ + type: 'update-note', + noteId, + content: newContent + }); + }; + + return ( +
+
Connection: {isConnected ? '🟢' : '🔴'}
+ +
+
+ + + `); + + this.setupStyles(); + this.bindEvents(); + } + + setupStyles() { + this.cssBlock(` + .markdown-type-widget { + height: 100%; + display: flex; + flex-direction: column; + } + + .markdown-toolbar { + padding: 10px; + border-bottom: 1px solid var(--main-border-color); + display: flex; + gap: 10px; + } + + .markdown-container { + flex: 1; + display: flex; + overflow: hidden; + } + + .markdown-editor, + .markdown-preview { + flex: 1; + padding: 20px; + overflow-y: auto; + } + + .markdown-editor { + border-right: 1px solid var(--main-border-color); + } + + .markdown-input { + width: 100%; + height: 100%; + border: none; + outline: none; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 14px; + line-height: 1.6; + resize: none; + background: var(--main-background-color); + color: var(--main-text-color); + } + + .markdown-preview { + background: var(--main-background-color); + } + + .markdown-preview h1 { + font-size: 2em; + margin: 0.67em 0; + border-bottom: 1px solid var(--main-border-color); + padding-bottom: 0.3em; + } + + .markdown-preview h2 { + font-size: 1.5em; + margin: 0.75em 0; + border-bottom: 1px solid var(--main-border-color); + padding-bottom: 0.3em; + } + + .markdown-preview code { + background: var(--code-background-color); + padding: 2px 4px; + border-radius: 3px; + font-family: 'Monaco', 'Courier New', monospace; + } + + .markdown-preview pre { + background: var(--code-background-color); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + } + + .markdown-preview blockquote { + border-left: 4px solid var(--primary-color); + margin: 0; + padding-left: 16px; + color: var(--muted-text-color); + } + + .markdown-preview table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + + .markdown-preview th, + .markdown-preview td { + border: 1px solid var(--main-border-color); + padding: 8px 12px; + } + + .markdown-preview th { + background: var(--button-background-color); + font-weight: 600; + } + + .markdown-type-widget.preview-only .markdown-editor { + display: none; + } + + .markdown-type-widget.preview-only .markdown-preview { + border-right: none; + } + + .markdown-type-widget.edit-only .markdown-preview { + display: none; + } + + .markdown-type-widget.edit-only .markdown-editor { + border-right: none; + } + `); + } + + bindEvents() { + const $input = this.$widget.find('.markdown-input'); + const $preview = this.$widget.find('.markdown-preview'); + + // Text input handler + $input.on('input', () => { + const content = $input.val() as string; + this.updatePreview(content); + this.spacedUpdate.scheduleUpdate(); + }); + + // Toolbar buttons + this.$widget.find('.toggle-edit').on('click', () => { + this.$widget.toggleClass('edit-only'); + this.$widget.removeClass('preview-only'); + }); + + this.$widget.find('.toggle-preview').on('click', () => { + this.$widget.toggleClass('preview-only'); + this.$widget.removeClass('edit-only'); + }); + + this.$widget.find('.export-html').on('click', () => { + this.exportAsHtml(); + }); + + // Keyboard shortcuts + $input.on('keydown', (e) => { + this.handleKeyboard(e); + }); + } + + handleKeyboard(e: JQuery.KeyDownEvent) { + const $input = $(e.target); + + // Tab handling for lists + if (e.key === 'Tab') { + e.preventDefault(); + const start = ($input[0] as HTMLTextAreaElement).selectionStart; + const end = ($input[0] as HTMLTextAreaElement).selectionEnd; + const value = $input.val() as string; + + $input.val(value.substring(0, start) + ' ' + value.substring(end)); + ($input[0] as HTMLTextAreaElement).selectionStart = + ($input[0] as HTMLTextAreaElement).selectionEnd = start + 4; + } + + // Bold shortcut (Ctrl+B) + if (e.ctrlKey && e.key === 'b') { + e.preventDefault(); + this.wrapSelection('**', '**'); + } + + // Italic shortcut (Ctrl+I) + if (e.ctrlKey && e.key === 'i') { + e.preventDefault(); + this.wrapSelection('*', '*'); + } + + // Link shortcut (Ctrl+K) + if (e.ctrlKey && e.key === 'k') { + e.preventDefault(); + this.insertLink(); + } + } + + wrapSelection(before: string, after: string) { + const $input = this.$widget.find('.markdown-input'); + const textarea = $input[0] as HTMLTextAreaElement; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = $input.val() as string; + const selection = value.substring(start, end); + + const newValue = value.substring(0, start) + + before + selection + after + + value.substring(end); + + $input.val(newValue); + textarea.selectionStart = start + before.length; + textarea.selectionEnd = end + before.length; + + $input.trigger('input'); + } + + async insertLink() { + const url = prompt('Enter URL:'); + if (url) { + const $input = this.$widget.find('.markdown-input'); + const textarea = $input[0] as HTMLTextAreaElement; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = $input.val() as string; + const selection = value.substring(start, end) || 'link text'; + + const link = `[${selection}](${url})`; + const newValue = value.substring(0, start) + link + value.substring(end); + + $input.val(newValue); + $input.trigger('input'); + } + } + + updatePreview(content: string) { + // Configure marked options + marked.setOptions({ + breaks: true, + gfm: true, + tables: true, + sanitize: false, + smartLists: true, + smartypants: true, + highlight: (code, lang) => { + // Add syntax highlighting if available + if (window.hljs && lang && window.hljs.getLanguage(lang)) { + return window.hljs.highlight(code, { language: lang }).value; + } + return code; + } + }); + + // Convert markdown to HTML + const html = marked.parse(content); + + // Update preview + this.$widget.find('.markdown-preview').html(html); + + // Process internal links + this.processInternalLinks(); + } + + processInternalLinks() { + this.$widget.find('.markdown-preview a').each((_, el) => { + const $link = $(el); + const href = $link.attr('href'); + + // Check for internal note links + if (href?.startsWith('#')) { + const noteId = href.substring(1); + $link.on('click', async (e) => { + e.preventDefault(); + const note = await froca.getNote(noteId); + if (note) { + appContext.tabManager.getActiveContext()?.setNote(noteId); + } + }); + } + }); + } + + async doRefresh(note) { + this.note = note; + const content = await this.getContent(); + + this.$widget.find('.markdown-input').val(content); + this.updatePreview(content); + + this.lastContent = content; + } + + async getContent() { + return await this.note.getContent(); + } + + async saveContent() { + const content = this.$widget.find('.markdown-input').val() as string; + + if (content === this.lastContent) { + return; // No changes + } + + try { + await server.put(`notes/${this.note.noteId}/content`, { + content: content + }); + + this.lastContent = content; + + } catch (error) { + toastService.showError('Failed to save markdown content'); + console.error('Save error:', error); + } + } + + async exportAsHtml() { + const content = this.$widget.find('.markdown-input').val() as string; + const html = marked.parse(content); + + // Create full HTML document + const fullHtml = ` + + + + + ${this.note.title} + + + + ${html} + + + `; + + // Download file + const blob = new Blob([fullHtml], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.note.title}.html`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage('Markdown exported as HTML'); + } + + cleanup() { + this.$widget.find('.markdown-input').off(); + this.$widget.find('button').off(); + this.spacedUpdate = null; + } +} +``` + +### Step 3: Register the Widget + +```typescript +// apps/client/src/services/note_type_registry.ts + +import MarkdownTypeWidget from "../widgets/type_widgets/markdown.js"; + +export function registerNoteTypes() { + // ... existing registrations + + noteTypeService.register(MarkdownTypeWidget); +} +``` + +### Step 4: Add Backend Support + +```typescript +// apps/server/src/services/notes.ts + +// Add to note creation +export async function createNote(params: NoteParams) { + // ... existing code + + if (params.type === 'markdown') { + // Set appropriate MIME type + params.mime = 'text/markdown'; + + // Initialize with template if needed + if (!params.content) { + params.content = '# New Markdown Note\n\nStart writing...'; + } + } + + // ... rest of creation logic +} + +// Add import support +export async function importMarkdown(filePath: string, parentNoteId: string) { + const fs = require('fs').promises; + const content = await fs.readFile(filePath, 'utf8'); + + const note = await createNote({ + parentNoteId, + title: path.basename(filePath, '.md'), + content, + type: 'markdown', + mime: 'text/markdown' + }); + + return note; +} + +// Add export support +export async function exportMarkdown(noteId: string, targetPath: string) { + const note = await becca.getNote(noteId); + const content = await note.getContent(); + + const fs = require('fs').promises; + await fs.writeFile(targetPath, content, 'utf8'); +} +``` + +## Complete Example: Markdown Preview Note Type + +Here's a full implementation of a markdown note type with live preview: + +### Widget Implementation + +```typescript +// apps/client/src/widgets/type_widgets/markdown_preview.ts + +import TypeWidget from "./type_widget.js"; +import SpacedUpdate from "../../services/spaced_update.js"; +import server from "../../services/server.js"; +import toastService from "../../services/toast.js"; +import appContext from "../../components/app_context.js"; +import froca from "../../services/froca.js"; +import linkService from "../../services/link.js"; +import utils from "../../services/utils.js"; + +interface MarkdownConfig { + splitView: boolean; + syncScroll: boolean; + showLineNumbers: boolean; + theme: 'light' | 'dark' | 'auto'; +} + +export default class MarkdownPreviewWidget extends TypeWidget { + static getType() { + return "markdownPreview"; + } + + private config: MarkdownConfig; + private editor: any; // CodeMirror instance + private spacedUpdate: SpacedUpdate; + private isRendering: boolean = false; + + constructor() { + super(); + + this.config = { + splitView: true, + syncScroll: true, + showLineNumbers: true, + theme: 'auto' + }; + + this.spacedUpdate = new SpacedUpdate(async () => { + await this.saveContent(); + }, 1000); + } + + doRender() { + this.$widget = $(` +
+
+
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+
+ +
+ + + +
+
+ +
+
+ +
+
+
+
+
+ + +
+ `); + + this.setupStyles(); + this.initializeEditor(); + this.bindEvents(); + } + + setupStyles() { + this.cssBlock(` + .markdown-preview-widget { + height: 100%; + display: flex; + flex-direction: column; + background: var(--main-background-color); + } + + .markdown-header { + border-bottom: 1px solid var(--main-border-color); + padding: 8px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + .markdown-toolbar { + display: flex; + gap: 12px; + flex-wrap: wrap; + } + + .markdown-toolbar .btn-group { + display: flex; + gap: 2px; + } + + .markdown-toolbar .btn { + padding: 4px 8px; + min-width: 32px; + } + + .markdown-view-controls { + display: flex; + gap: 2px; + } + + .markdown-content { + flex: 1; + display: flex; + overflow: hidden; + } + + .markdown-editor-container, + .markdown-preview-container { + flex: 1; + overflow: auto; + } + + .markdown-editor-container { + border-right: 1px solid var(--main-border-color); + } + + .CodeMirror { + height: 100%; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 14px; + } + + .markdown-preview { + padding: 20px; + max-width: 900px; + margin: 0 auto; + } + + /* Markdown preview styles */ + .markdown-preview h1 { + font-size: 2.5em; + margin: 0.67em 0; + padding-bottom: 0.3em; + border-bottom: 2px solid var(--main-border-color); + } + + .markdown-preview h2 { + font-size: 2em; + margin: 0.75em 0; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--main-border-color); + } + + .markdown-preview h3 { + font-size: 1.5em; + margin: 0.83em 0; + } + + .markdown-preview h4 { + font-size: 1.2em; + margin: 1em 0; + } + + .markdown-preview p { + margin: 1em 0; + line-height: 1.7; + } + + .markdown-preview code { + background: var(--code-background-color); + padding: 2px 6px; + border-radius: 3px; + font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.9em; + } + + .markdown-preview pre { + background: var(--code-background-color); + padding: 16px; + border-radius: 6px; + overflow-x: auto; + line-height: 1.45; + } + + .markdown-preview pre code { + background: none; + padding: 0; + } + + .markdown-preview blockquote { + border-left: 4px solid var(--primary-color); + margin: 1em 0; + padding: 0.5em 1em; + color: var(--muted-text-color); + background: var(--button-background-color); + } + + .markdown-preview ul, + .markdown-preview ol { + margin: 1em 0; + padding-left: 2em; + } + + .markdown-preview li { + margin: 0.5em 0; + } + + .markdown-preview table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + + .markdown-preview th, + .markdown-preview td { + border: 1px solid var(--main-border-color); + padding: 8px 12px; + text-align: left; + } + + .markdown-preview th { + background: var(--button-background-color); + font-weight: 600; + } + + .markdown-preview img { + max-width: 100%; + height: auto; + display: block; + margin: 1em auto; + } + + .markdown-preview a { + color: var(--link-color); + text-decoration: none; + } + + .markdown-preview a:hover { + text-decoration: underline; + } + + .markdown-preview hr { + border: none; + border-top: 2px solid var(--main-border-color); + margin: 2em 0; + } + + .markdown-preview .task-list-item { + list-style: none; + margin-left: -1.5em; + } + + .markdown-preview .task-list-item input[type="checkbox"] { + margin-right: 0.5em; + } + + .markdown-footer { + border-top: 1px solid var(--main-border-color); + padding: 8px 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .markdown-stats { + display: flex; + gap: 20px; + font-size: 0.9em; + color: var(--muted-text-color); + } + + .stat-label { + margin-right: 4px; + } + + .stat-value { + font-weight: 600; + color: var(--main-text-color); + } + + /* View modes */ + .markdown-preview-widget.edit-mode .markdown-preview-container { + display: none; + } + + .markdown-preview-widget.edit-mode .markdown-editor-container { + border-right: none; + } + + .markdown-preview-widget.preview-mode .markdown-editor-container { + display: none; + } + + /* Syntax highlighting */ + .hljs { + background: var(--code-background-color); + color: var(--main-text-color); + } + `); + } + + initializeEditor() { + // Initialize CodeMirror + const textarea = this.$widget.find('.markdown-editor')[0]; + + this.editor = CodeMirror.fromTextArea(textarea, { + mode: 'markdown', + lineNumbers: this.config.showLineNumbers, + lineWrapping: true, + theme: this.getEditorTheme(), + extraKeys: { + 'Ctrl-B': () => this.insertFormatting('bold'), + 'Ctrl-I': () => this.insertFormatting('italic'), + 'Ctrl-K': () => this.insertFormatting('link'), + 'Tab': 'indentMore', + 'Shift-Tab': 'indentLess' + } + }); + + // Handle editor changes + this.editor.on('change', () => { + this.handleContentChange(); + }); + + // Sync scroll if enabled + if (this.config.syncScroll) { + this.setupScrollSync(); + } + } + + getEditorTheme() { + if (this.config.theme === 'auto') { + const isDark = $('body').hasClass('theme-dark'); + return isDark ? 'monokai' : 'default'; + } + return this.config.theme === 'dark' ? 'monokai' : 'default'; + } + + setupScrollSync() { + const editorScroll = this.$widget.find('.CodeMirror-scroll'); + const previewScroll = this.$widget.find('.markdown-preview-container'); + + let syncingScroll = false; + + editorScroll.on('scroll', () => { + if (syncingScroll) return; + syncingScroll = true; + + const percentage = editorScroll.scrollTop() / + (editorScroll[0].scrollHeight - editorScroll.height()); + + previewScroll.scrollTop( + percentage * (previewScroll[0].scrollHeight - previewScroll.height()) + ); + + setTimeout(() => syncingScroll = false, 100); + }); + + previewScroll.on('scroll', () => { + if (syncingScroll) return; + syncingScroll = true; + + const percentage = previewScroll.scrollTop() / + (previewScroll[0].scrollHeight - previewScroll.height()); + + editorScroll.scrollTop( + percentage * (editorScroll[0].scrollHeight - editorScroll.height()) + ); + + setTimeout(() => syncingScroll = false, 100); + }); + } + + bindEvents() { + // Toolbar buttons + this.$widget.on('click', '[data-action]', (e) => { + const action = $(e.currentTarget).attr('data-action'); + this.handleAction(action!); + }); + + // View mode buttons + this.$widget.on('click', '[data-view]', (e) => { + const $btn = $(e.currentTarget); + const view = $btn.attr('data-view'); + + this.$widget.find('[data-view]').removeClass('active'); + $btn.addClass('active'); + + this.$widget.removeClass('edit-mode preview-mode'); + if (view === 'edit') { + this.$widget.addClass('edit-mode'); + } else if (view === 'preview') { + this.$widget.addClass('preview-mode'); + } + }); + } + + handleContentChange() { + const content = this.editor.getValue(); + + // Update preview + this.renderPreview(content); + + // Update statistics + this.updateStatistics(content); + + // Schedule save + this.spacedUpdate.scheduleUpdate(); + } + + renderPreview(content: string) { + if (this.isRendering) return; + this.isRendering = true; + + // Use marked.js for markdown rendering + const marked = window.marked; + + marked.setOptions({ + breaks: true, + gfm: true, + tables: true, + smartLists: true, + smartypants: true, + highlight: (code, lang) => { + if (window.hljs && lang && window.hljs.getLanguage(lang)) { + try { + return window.hljs.highlight(code, { language: lang }).value; + } catch (e) { + console.error('Highlight error:', e); + } + } + return code; + } + }); + + try { + const html = marked.parse(content); + this.$widget.find('.markdown-preview').html(html); + + // Process internal links + this.processLinks(); + + // Process checkboxes + this.processCheckboxes(); + + } catch (error) { + console.error('Markdown render error:', error); + } + + this.isRendering = false; + } + + processLinks() { + this.$widget.find('.markdown-preview a').each((_, el) => { + const $link = $(el); + const href = $link.attr('href'); + + if (!href) return; + + // Internal note links (#noteId) + if (href.startsWith('#')) { + const noteId = href.substring(1); + $link.on('click', async (e) => { + e.preventDefault(); + await appContext.tabManager.getActiveContext()?.setNote(noteId); + }); + } + // External links + else if (href.startsWith('http')) { + $link.attr('target', '_blank'); + $link.attr('rel', 'noopener noreferrer'); + } + }); + } + + processCheckboxes() { + this.$widget.find('.markdown-preview input[type="checkbox"]').each((i, el) => { + const $checkbox = $(el); + const $li = $checkbox.closest('li'); + + $li.addClass('task-list-item'); + + $checkbox.on('change', () => { + const isChecked = $checkbox.is(':checked'); + this.updateTaskInEditor(i, isChecked); + }); + }); + } + + updateTaskInEditor(index: number, checked: boolean) { + const content = this.editor.getValue(); + const lines = content.split('\n'); + + let taskCount = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].match(/^\s*[-*+]\s+\[[ x]\]/)) { + if (taskCount === index) { + lines[i] = lines[i].replace( + /\[[ x]\]/, + checked ? '[x]' : '[ ]' + ); + break; + } + taskCount++; + } + } + + this.editor.setValue(lines.join('\n')); + } + + updateStatistics(content: string) { + const words = content.match(/\b\w+\b/g)?.length || 0; + const chars = content.length; + const lines = content.split('\n').length; + + this.$widget.find('[data-stat="words"]').text(words); + this.$widget.find('[data-stat="chars"]').text(chars); + this.$widget.find('[data-stat="lines"]').text(lines); + } + + handleAction(action: string) { + switch (action) { + case 'bold': + case 'italic': + case 'strikethrough': + case 'h1': + case 'h2': + case 'h3': + case 'ul': + case 'ol': + case 'task': + case 'quote': + case 'code': + case 'codeblock': + case 'link': + case 'image': + case 'table': + case 'hr': + this.insertFormatting(action); + break; + + case 'export-html': + this.exportAsHtml(); + break; + + case 'export-pdf': + this.exportAsPdf(); + break; + } + } + + insertFormatting(type: string) { + const cursor = this.editor.getCursor(); + const selection = this.editor.getSelection(); + + const formats: Record = { + bold: { wrap: '**' }, + italic: { wrap: '*' }, + strikethrough: { wrap: '~~' }, + h1: { prefix: '# ' }, + h2: { prefix: '## ' }, + h3: { prefix: '### ' }, + ul: { prefix: '- ' }, + ol: { prefix: '1. ' }, + task: { prefix: '- [ ] ' }, + quote: { prefix: '> ' }, + code: { wrap: '`' }, + codeblock: { + before: '```\n', + after: '\n```' + }, + link: { + template: '[${text}](${url})' + }, + image: { + template: '![${alt}](${url})' + }, + table: { + template: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |' + }, + hr: { + insert: '\n---\n' + } + }; + + const format = formats[type]; + if (!format) return; + + if (format.wrap) { + const wrapped = format.wrap + (selection || 'text') + format.wrap; + this.editor.replaceSelection(wrapped); + } else if (format.prefix) { + this.editor.setCursor({ line: cursor.line, ch: 0 }); + this.editor.replaceRange(format.prefix, cursor); + } else if (format.before && format.after) { + const text = format.before + (selection || '') + format.after; + this.editor.replaceSelection(text); + } else if (format.template) { + // Handle templates with placeholders + if (type === 'link') { + const url = prompt('Enter URL:') || ''; + const text = selection || 'link text'; + this.editor.replaceSelection(`[${text}](${url})`); + } else if (type === 'image') { + const url = prompt('Enter image URL:') || ''; + const alt = selection || 'alt text'; + this.editor.replaceSelection(`![${alt}](${url})`); + } else { + this.editor.replaceSelection(format.template); + } + } else if (format.insert) { + this.editor.replaceSelection(format.insert); + } + + this.editor.focus(); + } + + async doRefresh(note) { + this.note = note; + + const content = await note.getContent(); + this.editor.setValue(content); + + this.renderPreview(content); + this.updateStatistics(content); + } + + async saveContent() { + if (!this.note) return; + + const content = this.editor.getValue(); + + try { + await server.put(`notes/${this.note.noteId}/content`, { + content: content + }); + } catch (error) { + console.error('Save error:', error); + toastService.showError('Failed to save markdown content'); + } + } + + async exportAsHtml() { + const content = this.editor.getValue(); + const html = marked.parse(content); + + const fullHtml = ` + + + + + ${this.note.title} + + + +
+ ${html} +
+ + + `; + + const blob = new Blob([fullHtml], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${this.note.title}.html`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage('Exported as HTML'); + } + + async exportAsPdf() { + // This would require a backend service or library like jsPDF + toastService.showMessage('PDF export not yet implemented'); + } + + getExportStyles() { + return ` + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 900px; + margin: 0 auto; + padding: 20px; + } + /* ... additional export styles ... */ + `; + } + + cleanup() { + if (this.editor) { + this.editor.toTextArea(); + } + this.$widget.off('click'); + this.spacedUpdate = null; + } +} +``` + +## Advanced Features + +### Custom Import/Export + +```typescript +// apps/server/src/services/import_export/markdown_handler.ts + +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; + +export class MarkdownImportExport { + async importMarkdownFile(filePath: string, parentNoteId: string) { + const content = await fs.readFile(filePath, 'utf8'); + + // Parse frontmatter if present + const { data: metadata, content: body } = matter(content); + + // Create note + const note = await api.createNote( + parentNoteId, + metadata.title || path.basename(filePath, '.md'), + body + ); + + // Set note type + note.type = 'markdown'; + note.mime = 'text/markdown'; + + // Add metadata as attributes + if (metadata.tags) { + for (const tag of metadata.tags) { + await note.addLabel('tag', tag); + } + } + + if (metadata.date) { + await note.addLabel('created', metadata.date); + } + + await note.save(); + return note; + } + + async exportMarkdownFile(noteId: string, targetDir: string) { + const note = await api.getNote(noteId); + const content = await note.getContent(); + + // Build frontmatter + const metadata: any = { + title: note.title, + date: note.dateCreated, + modified: note.dateModified + }; + + // Add tags + const tags = note.getLabels() + .filter(l => l.name === 'tag') + .map(l => l.value); + + if (tags.length > 0) { + metadata.tags = tags; + } + + // Create markdown with frontmatter + const markdown = matter.stringify(content, metadata); + + // Write file + const fileName = `${note.title.replace(/[^a-z0-9]/gi, '_')}.md`; + const filePath = path.join(targetDir, fileName); + + await fs.writeFile(filePath, markdown, 'utf8'); + + return filePath; + } +} +``` + +### Custom Actions and Commands + +```typescript +// Add custom actions for the note type +class MarkdownActions { + static registerActions() { + // Register command palette actions + api.addCommand({ + name: 'markdown:togglePreview', + label: 'Markdown: Toggle Preview', + action: async () => { + const widget = api.getActiveWidget(); + if (widget instanceof MarkdownPreviewWidget) { + widget.togglePreview(); + } + } + }); + + // Register context menu items + api.addContextMenuItem({ + noteType: 'markdown', + label: 'Convert to HTML', + action: async (note) => { + await this.convertToHtml(note); + } + }); + } + + static async convertToHtml(note) { + const content = await note.getContent(); + const html = marked.parse(content); + + // Create new HTML note + const htmlNote = await api.createNote( + note.getParentNoteIds()[0], + `${note.title} (HTML)`, + html + ); + + htmlNote.type = 'text'; + htmlNote.mime = 'text/html'; + await htmlNote.save(); + + toastService.showMessage('Converted to HTML note'); + } +} +``` + +## Testing Your Note Type + +```typescript +// apps/client/test/widgets/markdown_preview.test.ts + +import MarkdownPreviewWidget from '../../src/widgets/type_widgets/markdown_preview'; + +describe('MarkdownPreviewWidget', () => { + let widget: MarkdownPreviewWidget; + let mockNote: any; + + beforeEach(() => { + widget = new MarkdownPreviewWidget(); + mockNote = { + noteId: 'test123', + title: 'Test Note', + type: 'markdown', + getContent: jest.fn().mockResolvedValue('# Test\n\nContent'), + setContent: jest.fn() + }; + }); + + test('renders markdown correctly', async () => { + widget.doRender(); + await widget.doRefresh(mockNote); + + const preview = widget.$widget.find('.markdown-preview').html(); + expect(preview).toContain('

Test

'); + expect(preview).toContain('

Content

'); + }); + + test('handles formatting shortcuts', () => { + widget.doRender(); + widget.initializeEditor(); + + // Test bold formatting + widget.editor.setValue('test'); + widget.editor.setSelection( + { line: 0, ch: 0 }, + { line: 0, ch: 4 } + ); + widget.insertFormatting('bold'); + + expect(widget.editor.getValue()).toBe('**test**'); + }); + + test('saves content on change', async () => { + jest.useFakeTimers(); + + widget.doRender(); + await widget.doRefresh(mockNote); + + // Change content + widget.editor.setValue('New content'); + + // Wait for debounce + jest.advanceTimersByTime(1100); + + expect(server.put).toHaveBeenCalledWith( + 'notes/test123/content', + { content: 'New content' } + ); + }); +}); +``` + +## Best Practices + +1. **Performance** + + * Debounce saves and preview updates + * Use virtual scrolling for large documents + * Cache rendered content when possible +2. **User Experience** + + * Provide keyboard shortcuts + * Show visual feedback for actions + * Maintain cursor position on refresh +3. **Data Integrity** + + * Validate content before saving + * Handle conflicts gracefully + * Provide undo/redo functionality +4. **Extensibility** + + * Use configuration options + * Support plugins/extensions + * Provide hooks for customization +5. **Testing** + + * Test rendering edge cases + * Verify import/export functionality + * Test keyboard shortcuts and actions + +## Troubleshooting + +### Widget Not Loading + +* Check type registration +* Verify MIME type matches +* Check console for errors + +### Content Not Saving + +* Verify backend handler +* Check network requests +* Review error logs + +### Preview Not Updating + +* Check markdown parser +* Verify event bindings +* Debug render function + +### Performance Issues + +* Profile rendering +* Optimize DOM updates +* Implement virtual scrolling + +## Next Steps + +* Review the Theme Development Guide +* Explore existing note type implementations +* Join the community to share your custom types \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md b/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md new file mode 100644 index 00000000000..9405f6a8410 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/Plugin Development/Custom Widget Development Guid.md @@ -0,0 +1,694 @@ +# Custom Widget Development Guide +Widgets are the building blocks of Trilium's user interface. This guide shows you how to create your own widgets to extend Trilium with custom functionality. + +## Getting Started + +To develop widgets, you'll need basic JavaScript knowledge and familiarity with jQuery. Widgets in Trilium follow a simple hierarchy where each type adds specific capabilities - BasicWidget for general UI, NoteContextAwareWidget for note-responsive widgets, and RightPanelWidget for sidebar panels. + +## Creating Your First Widget + +### Basic Widget + +Start with a simple widget that displays static content: + +```javascript +class MyWidget extends BasicWidget { + doRender() { + this.$widget = $('
Hello from my widget!
'); + } +} +``` + +### Note-Aware Widget + +To make your widget respond to note changes, extend NoteContextAwareWidget: + +```javascript +class NoteInfoWidget extends NoteContextAwareWidget { + doRender() { + this.$widget = $('
'); + } + + async refreshWithNote(note) { + this.$widget.html(` +

${note.title}

+

Type: ${note.type}

+ `); + } +} +``` + +The `refreshWithNote` method is automatically called whenever the user switches to a different note. + +### Right Panel Widget + +For widgets in the sidebar, extend RightPanelWidget: + +```javascript +class StatsWidget extends RightPanelWidget { + get widgetTitle() { return "Statistics"; } + + async doRenderBody() { + this.$body.html('
Loading...
'); + } + + async refreshWithNote(note) { + const content = await note.getContent(); + const words = content.split(/\s+/).length; + this.$body.find('.stats').text(`Words: ${words}`); + } +} +``` + +## Widget Lifecycle + +Widgets go through three main phases: + +**Initialization**: The `doRender()` method creates your widget's HTML structure. This happens once when the widget is first displayed. + +**Updates**: The `refresh()` or `refreshWithNote()` methods update your widget's content. These are called when data changes or the user switches notes. + +**Cleanup**: If your widget creates timers or external connections, override `cleanup()` to properly dispose of them. + +## Handling Events + +Widgets automatically subscribe to events based on method names. Simply define a method ending with "Event" to handle that event: + +```javascript +class ReactiveWidget extends NoteContextAwareWidget { + // Triggered when note content changes + async noteContentChangedEvent({ noteId }) { + if (this.noteId === noteId) { + await this.refresh(); + } + } + + // Triggered when user switches notes + async noteSwitchedEvent() { + console.log('Switched to:', this.noteId); + } +} +``` + +Common events include `noteSwitched`, `noteContentChanged`, and `entitiesReloaded`. The event system ensures your widget stays synchronized with Trilium's state. + +## State Management + +### Local State + +Store widget-specific state in instance properties: + +```typescript +class StatefulWidget extends BasicWidget { + constructor() { + super(); + this.isExpanded = false; + this.cachedData = null; + } + + toggleExpanded() { + this.isExpanded = !this.isExpanded; + this.$widget.toggleClass('expanded', this.isExpanded); + } +} +``` + +### Persistent State + +Use options or attributes for persistent state: + +```typescript +class PersistentWidget extends NoteContextAwareWidget { + async saveState(state) { + await server.put('options', { + name: 'widgetState', + value: JSON.stringify(state) + }); + } + + async loadState() { + const option = await server.get('options/widgetState'); + return option ? JSON.parse(option.value) : {}; + } +} +``` + +## Accessing Trilium APIs + +### Frontend Services + +```typescript +import froca from "../services/froca.js"; +import server from "../services/server.js"; +import linkService from "../services/link.js"; +import toastService from "../services/toast.js"; +import dialogService from "../services/dialog.js"; + +class ApiWidget extends NoteContextAwareWidget { + async doRenderBody() { + // Access notes + const note = await froca.getNote(this.noteId); + + // Get attributes + const attributes = note.getAttributes(); + + // Create links + const $link = await linkService.createLink(note.noteId); + + // Show notifications + toastService.showMessage("Widget loaded"); + + // Open dialogs + const result = await dialogService.confirm("Continue?"); + } +} +``` + +### Server Communication + +```typescript +class ServerWidget extends BasicWidget { + async loadData() { + // GET request + const data = await server.get('custom-api/data'); + + // POST request + const result = await server.post('custom-api/process', { + noteId: this.noteId, + action: 'analyze' + }); + + // PUT request + await server.put(`notes/${this.noteId}`, { + title: 'Updated Title' + }); + } +} +``` + +## Styling Widgets + +### Inline Styles + +```typescript +class StyledWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + this.css('padding', '10px') + .css('background-color', '#f0f0f0') + .css('border-radius', '4px'); + } +} +``` + +### CSS Classes + +```typescript +class ClassedWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + this.class('custom-widget') + .class('bordered'); + } +} +``` + +### CSS Blocks + +```typescript +class CSSBlockWidget extends BasicWidget { + doRender() { + this.$widget = $('
Content
'); + + this.cssBlock(` + .my-widget { + padding: 15px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 8px; + } + + .my-widget:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + } + `); + } +} +``` + +## Performance Optimization + +### Lazy Loading + +```typescript +class LazyWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.dataLoaded = false; + } + + async refreshWithNote(note) { + if (!this.isVisible()) { + return; // Don't load if not visible + } + + if (!this.dataLoaded) { + await this.loadExpensiveData(); + this.dataLoaded = true; + } + + this.updateDisplay(); + } +} +``` + +### Debouncing Updates + +```typescript +import SpacedUpdate from "../services/spaced_update.js"; + +class DebouncedWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.spacedUpdate = new SpacedUpdate(async () => { + await this.performUpdate(); + }, 500); // 500ms delay + } + + async handleInput(value) { + await this.spacedUpdate.scheduleUpdate(); + } +} +``` + +### Caching + +```typescript +class CachedWidget extends NoteContextAwareWidget { + constructor() { + super(); + this.cache = new Map(); + } + + async getProcessedData(noteId) { + if (!this.cache.has(noteId)) { + const data = await this.processExpensiveOperation(noteId); + this.cache.set(noteId, data); + } + return this.cache.get(noteId); + } + + cleanup() { + this.cache.clear(); + } +} +``` + +## Debugging Widgets + +### Console Logging + +```typescript +class DebugWidget extends BasicWidget { + doRender() { + console.log('Widget rendering', this.componentId); + console.time('render'); + + this.$widget = $('
'); + + console.timeEnd('render'); + } +} +``` + +### Error Handling + +```typescript +class SafeWidget extends NoteContextAwareWidget { + async refreshWithNote(note) { + try { + await this.riskyOperation(); + } catch (error) { + console.error('Widget error:', error); + this.logRenderingError(error); + this.$widget.html('
Failed to load
'); + } + } +} +``` + +### Development Tools + +```typescript +class DevWidget extends BasicWidget { + doRender() { + this.$widget = $('
'); + + // Add debug information in development + if (window.glob.isDev) { + this.$widget.attr('data-debug', 'true'); + this.$widget.append(` +
+ Component ID: ${this.componentId} + Position: ${this.position} +
+ `); + } + } +} +``` + +## Complete Example: Note Statistics Widget + +Here's a complete example implementing a custom note statistics widget: + +```typescript +import RightPanelWidget from "../widgets/right_panel_widget.js"; +import server from "../services/server.js"; +import froca from "../services/froca.js"; +import toastService from "../services/toast.js"; +import SpacedUpdate from "../services/spaced_update.js"; + +class NoteStatisticsWidget extends RightPanelWidget { + constructor() { + super(); + + // Initialize state + this.statistics = { + words: 0, + characters: 0, + paragraphs: 0, + readingTime: 0, + links: 0, + images: 0 + }; + + // Debounce updates for performance + this.spacedUpdate = new SpacedUpdate(async () => { + await this.calculateStatistics(); + }, 300); + } + + get widgetTitle() { + return "Note Statistics"; + } + + get help() { + return { + title: "Note Statistics", + text: "Displays various statistics about the current note including word count, reading time, and more." + }; + } + + async doRenderBody() { + this.$body.html(` +
+
+
Content
+
+ Words: + 0 +
+
+ Characters: + 0 +
+
+ Paragraphs: + 0 +
+
+ +
+
Reading
+
+ Reading time: + 0 min +
+
+ +
+
Elements
+
+ Links: + 0 +
+
+ Images: + 0 +
+
+ +
+ + +
+
+ `); + + this.cssBlock(` + .note-statistics { + padding: 10px; + } + + .stat-group { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid var(--main-border-color); + } + + .stat-group:last-child { + border-bottom: none; + } + + .stat-group h5 { + margin: 0 0 10px 0; + color: var(--muted-text-color); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .stat-item { + display: flex; + justify-content: space-between; + padding: 5px 0; + } + + .stat-label { + color: var(--main-text-color); + } + + .stat-value { + font-weight: 600; + color: var(--primary-color); + } + + .stat-actions { + margin-top: 15px; + display: flex; + gap: 10px; + } + + .stat-actions .btn { + flex: 1; + } + `); + + // Bind events + this.$body.on('click', '.refresh-stats', () => this.handleRefresh()); + this.$body.on('click', '.export-stats', () => this.handleExport()); + } + + async refreshWithNote(note) { + if (!note) { + this.clearStatistics(); + return; + } + + // Schedule statistics calculation + await this.spacedUpdate.scheduleUpdate(); + } + + async calculateStatistics() { + try { + const note = this.note; + if (!note) return; + + const content = await note.getContent(); + + if (note.type === 'text') { + // Parse HTML content + const $content = $('
').html(content); + const textContent = $content.text(); + + // Calculate statistics + this.statistics.words = this.countWords(textContent); + this.statistics.characters = textContent.length; + this.statistics.paragraphs = $content.find('p').length; + this.statistics.readingTime = Math.ceil(this.statistics.words / 200); + this.statistics.links = $content.find('a').length; + this.statistics.images = $content.find('img').length; + } else if (note.type === 'code') { + // For code notes, count lines and characters + const lines = content.split('\n'); + this.statistics.words = lines.length; // Show lines instead of words + this.statistics.characters = content.length; + this.statistics.paragraphs = 0; + this.statistics.readingTime = 0; + this.statistics.links = 0; + this.statistics.images = 0; + } + + this.updateDisplay(); + + } catch (error) { + console.error('Failed to calculate statistics:', error); + toastService.showError("Failed to calculate statistics"); + } + } + + countWords(text) { + const words = text.match(/\b\w+\b/g); + return words ? words.length : 0; + } + + clearStatistics() { + this.statistics = { + words: 0, + characters: 0, + paragraphs: 0, + readingTime: 0, + links: 0, + images: 0 + }; + this.updateDisplay(); + } + + updateDisplay() { + this.$body.find('[data-stat="words"]').text(this.statistics.words); + this.$body.find('[data-stat="characters"]').text(this.statistics.characters); + this.$body.find('[data-stat="paragraphs"]').text(this.statistics.paragraphs); + this.$body.find('[data-stat="readingTime"]').text(`${this.statistics.readingTime} min`); + this.$body.find('[data-stat="links"]').text(this.statistics.links); + this.$body.find('[data-stat="images"]').text(this.statistics.images); + } + + async handleRefresh() { + await this.calculateStatistics(); + toastService.showMessage("Statistics refreshed"); + } + + async handleExport() { + const note = this.note; + if (!note) return; + + const exportData = { + noteId: note.noteId, + title: note.title, + statistics: this.statistics, + timestamp: new Date().toISOString() + }; + + // Create a CSV + const csv = [ + 'Metric,Value', + `Words,${this.statistics.words}`, + `Characters,${this.statistics.characters}`, + `Paragraphs,${this.statistics.paragraphs}`, + `Reading Time,${this.statistics.readingTime} minutes`, + `Links,${this.statistics.links}`, + `Images,${this.statistics.images}` + ].join('\n'); + + // Download CSV + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `statistics-${note.noteId}.csv`; + a.click(); + URL.revokeObjectURL(url); + + toastService.showMessage("Statistics exported"); + } + + async noteContentChangedEvent({ noteId }) { + if (this.noteId === noteId) { + await this.spacedUpdate.scheduleUpdate(); + } + } + + cleanup() { + this.$body.off('click'); + this.spacedUpdate = null; + } +} + +export default NoteStatisticsWidget; +``` + +## Best Practices + +### 1\. Memory Management + +* Clean up event listeners in `cleanup()` +* Clear caches and timers when widget is destroyed +* Avoid circular references + +### 2\. Performance + +* Use debouncing for frequent updates +* Implement lazy loading for expensive operations +* Cache computed values when appropriate + +### 3\. Error Handling + +* Always wrap async operations in try-catch +* Provide user feedback for errors +* Log errors for debugging + +### 4\. User Experience + +* Show loading states for async operations +* Provide clear error messages +* Ensure widgets are responsive + +### 5\. Code Organization + +* Keep widgets focused on a single responsibility +* Extract reusable logic into services +* Use composition over inheritance when possible + +## Troubleshooting + +### Widget Not Rendering + +* Check `doRender()` creates `this.$widget` +* Verify widget is properly registered +* Check console for errors + +### Events Not Firing + +* Ensure event method name matches pattern: `${eventName}Event` +* Check event is being triggered +* Verify widget is active/visible + +### State Not Persisting + +* Use options or attributes for persistence +* Check save operations complete successfully +* Verify data serialization + +### Performance Issues + +* Profile with browser dev tools +* Implement caching and debouncing +* Optimize DOM operations + +## Next Steps + +* Explore existing widgets in `/apps/client/src/widgets/` for examples +* Review the Frontend Script API documentation +* Join the Trilium community for support and sharing widgets \ No newline at end of file diff --git a/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md b/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md new file mode 100644 index 00000000000..04b31553904 --- /dev/null +++ b/docs/Developer Guide/Developer Guide/Plugin Development/Frontend Script Development.md @@ -0,0 +1,1042 @@ +# Frontend Script Development +## Frontend Script Development Guide + +This guide covers developing frontend scripts in Trilium Notes. Frontend scripts run in the browser context and can interact with the UI, modify behavior, and create custom functionality. + +## Prerequisites + +* JavaScript/TypeScript knowledge +* Understanding of browser APIs and DOM manipulation +* Basic knowledge of Trilium's note system +* Familiarity with async/await patterns + +## Getting Started + +### Creating a Frontend Script + +1. Create a new code note with type "JS Frontend" +2. Add the `#run=frontendStartup` label to run on startup +3. Write your JavaScript code + +```javascript +// Basic frontend script +api.addButtonToToolbar({ + title: 'My Custom Button', + icon: 'bx bx-star', + action: async () => { + await api.showMessage('Hello from custom script!'); + } +}); +``` + +### Script Execution Context + +Frontend scripts run in the browser with access to: + +* Trilium's Frontend API (`api` global object) +* Browser APIs (DOM, fetch, localStorage, etc.) +* jQuery (`$` global) +* All loaded libraries + +## Frontend API Reference + +### Core API Object + +The `api` object is globally available in all frontend scripts: + +```javascript +// Access current note +const currentNote = api.getActiveContextNote(); + +// Get note by ID +const note = await api.getNote('noteId123'); + +// Search notes +const results = await api.searchForNotes('type:text @label=important'); +``` + +### Note Operations + +#### Reading Notes + +```javascript +// Get active note +const activeNote = api.getActiveContextNote(); +console.log('Current note:', activeNote.title); + +// Get note by ID +const note = await api.getNote('noteId123'); + +// Get note content +const content = await note.getContent(); + +// Get note attributes +const attributes = note.getAttributes(); +const labels = note.getLabels(); +const relations = note.getRelations(); + +// Get child notes +const children = await note.getChildNotes(); + +// Get parent notes +const parents = await note.getParentNotes(); +``` + +#### Creating Notes + +```javascript +// Create a simple note +const newNote = await api.createNote( + parentNoteId, + 'New Note Title', + 'Note content here' +); + +// Create note with options +const note = await api.createNote( + parentNoteId, + 'Advanced Note', + '

HTML content

', + { + type: 'text', + mime: 'text/html', + isProtected: false + } +); + +// Create data note for storing JSON +const dataNote = await api.createDataNote( + parentNoteId, + 'config', + { key: 'value', settings: {} } +); +``` + +#### Modifying Notes + +```javascript +// Update note title +await note.setTitle('New Title'); + +// Update note content +await note.setContent('New content'); + +// Add label +await note.addLabel('status', 'completed'); + +// Add relation +await note.addRelation('relatedTo', targetNoteId); + +// Remove attribute +await note.removeAttribute(attributeId); + +// Toggle label +await note.toggleLabel('archived'); +await note.toggleLabel('priority', 'high'); +``` + +### UI Interaction + +#### Showing Messages + +```javascript +// Simple message +await api.showMessage('Operation completed'); + +// Error message +await api.showError('Something went wrong'); + +// Message with duration +await api.showMessage('Saved!', 3000); + +// Persistent message +const toast = await api.showPersistent({ + title: 'Processing', + message: 'Please wait...', + icon: 'loader' +}); + +// Close persistent message +toast.close(); +``` + +#### Dialogs + +```javascript +// Confirmation dialog +const confirmed = await api.showConfirmDialog({ + title: 'Delete Note?', + message: 'This action cannot be undone.', + okButtonLabel: 'Delete', + cancelButtonLabel: 'Keep' +}); + +if (confirmed) { + // Proceed with deletion +} + +// Prompt dialog +const input = await api.showPromptDialog({ + title: 'Enter Name', + message: 'Please enter a name for the new note:', + defaultValue: 'Untitled' +}); + +if (input) { + await api.createNote(parentId, input, ''); +} +``` + +### Custom Commands + +#### Adding Menu Items + +```javascript +// Add to note context menu +api.addContextMenuItemToNotes({ + title: 'Copy Note ID', + icon: 'bx bx-copy', + handler: async (note) => { + await navigator.clipboard.writeText(note.noteId); + await api.showMessage('Note ID copied'); + } +}); + +// Add to toolbar +api.addButtonToToolbar({ + title: 'Quick Action', + icon: 'bx bx-bolt', + shortcut: 'ctrl+shift+q', + action: async () => { + // Your action here + } +}); +``` + +#### Registering Commands + +```javascript +// Register a global command +api.bindGlobalShortcut('ctrl+shift+t', async () => { + const note = api.getActiveContextNote(); + const timestamp = new Date().toISOString(); + await note.addLabel('lastAccessed', timestamp); + await api.showMessage('Timestamp added'); +}); + +// Add command palette action +api.addCommandPaletteItem({ + name: 'Toggle Dark Mode', + description: 'Switch between light and dark themes', + action: async () => { + const currentTheme = await api.getOption('theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + await api.setOption('theme', newTheme); + } +}); +``` + +### Event Handling + +#### Listening to Events + +```javascript +// Note switch event +api.onNoteChange(async ({ note, previousNote }) => { + console.log(`Switched from ${previousNote?.title} to ${note.title}`); + + // Update custom UI + updateCustomPanel(note); +}); + +// Content change event +api.onNoteContentChange(async ({ note }) => { + console.log(`Content changed for ${note.title}`); + + // Auto-save to external service + await syncToExternalService(note); +}); + +// Attribute change event +api.onAttributeChange(async ({ note, attribute }) => { + if (attribute.name === 'status' && attribute.value === 'completed') { + await note.addLabel('completedDate', new Date().toISOString()); + } +}); +``` + +#### Custom Events + +```javascript +// Trigger custom event +api.triggerEvent('myCustomEvent', { data: 'value' }); + +// Listen to custom event +api.onCustomEvent('myCustomEvent', async (data) => { + console.log('Custom event received:', data); +}); +``` + +### Working with Widgets + +```javascript +// Access widget system +const widget = api.getWidget('NoteTreeWidget'); + +// Refresh widget +await widget.refresh(); + +// Create custom widget container +const container = api.createCustomWidget({ + title: 'My Widget', + position: 'left', + render: async () => { + return ` +
+

Custom Content

+ +
+ `; + } +}); +``` + +## Complete Example: Auto-Formatting Script + +Here's a comprehensive example that automatically formats notes based on their type: + +```javascript +/** + * Auto-Formatting Script + * Automatically formats notes based on their type and content + */ + +class NoteFormatter { + constructor() { + this.setupEventListeners(); + this.registerCommands(); + } + + setupEventListeners() { + // Format on note save + api.onNoteContentChange(async ({ note }) => { + if (await this.shouldAutoFormat(note)) { + await this.formatNote(note); + } + }); + + // Format when label added + api.onAttributeChange(async ({ note, attribute }) => { + if (attribute.type === 'label' && + attribute.name === 'autoFormat' && + attribute.value === 'true') { + await this.formatNote(note); + } + }); + } + + registerCommands() { + // Add toolbar button + api.addButtonToToolbar({ + title: 'Format Note', + icon: 'bx bx-text', + shortcut: 'ctrl+shift+f', + action: async () => { + const note = api.getActiveContextNote(); + await this.formatNote(note); + await api.showMessage('Note formatted'); + } + }); + + // Add context menu item + api.addContextMenuItemToNotes({ + title: 'Auto-Format', + icon: 'bx bx-magic', + handler: async (note) => { + await this.formatNote(note); + } + }); + } + + async shouldAutoFormat(note) { + // Check if note has autoFormat label + const labels = note.getLabels(); + return labels.some(l => l.name === 'autoFormat' && l.value === 'true'); + } + + async formatNote(note) { + const type = note.type; + + switch (type) { + case 'text': + await this.formatTextNote(note); + break; + case 'code': + await this.formatCodeNote(note); + break; + case 'book': + await this.formatBookNote(note); + break; + } + } + + async formatTextNote(note) { + let content = await note.getContent(); + + // Apply formatting rules + content = this.addTableOfContents(content); + content = this.formatHeadings(content); + content = this.formatLists(content); + content = this.addMetadata(content, note); + + await note.setContent(content); + } + + async formatCodeNote(note) { + const content = await note.getContent(); + const language = note.getLabelValue('language') || 'javascript'; + + // Add syntax highlighting hints + if (!note.hasLabel('language')) { + await note.addLabel('language', language); + } + + // Format based on language + if (language === 'javascript' || language === 'typescript') { + await this.formatJavaScript(note, content); + } else if (language === 'python') { + await this.formatPython(note, content); + } + } + + async formatBookNote(note) { + // Organize child notes + const children = await note.getChildNotes(); + + // Sort chapters + const chapters = children.filter(n => n.hasLabel('chapter')); + chapters.sort((a, b) => { + const aNum = parseInt(a.getLabelValue('chapter')) || 999; + const bNum = parseInt(b.getLabelValue('chapter')) || 999; + return aNum - bNum; + }); + + // Generate table of contents + const toc = this.generateBookTOC(chapters); + await note.setContent(toc); + } + + addTableOfContents(content) { + const $content = $('
').html(content); + const headings = $content.find('h1, h2, h3'); + + if (headings.length < 3) return content; + + let toc = '
\n

Table of Contents

\n
    \n'; + + headings.each((i, heading) => { + const $h = $(heading); + const level = parseInt(heading.tagName.substring(1)); + const text = $h.text(); + const id = `heading-${i}`; + + $h.attr('id', id); + + const indent = ' '.repeat(level - 1); + toc += `${indent}
  • ${text}
  • \n`; + }); + + toc += '
\n
\n\n'; + + return toc + $content.html(); + } + + formatHeadings(content) { + const $content = $('
').html(content); + + // Ensure proper heading hierarchy + let lastLevel = 0; + $content.find('h1, h2, h3, h4, h5, h6').each((i, heading) => { + const $h = $(heading); + const level = parseInt(heading.tagName.substring(1)); + + // Fix heading jumps (e.g., h1 -> h3 becomes h1 -> h2) + if (level > lastLevel + 1) { + const newTag = `h${lastLevel + 1}`; + const $newHeading = $(`<${newTag}>`).html($h.html()); + $h.replaceWith($newHeading); + } + + lastLevel = level; + }); + + return $content.html(); + } + + formatLists(content) { + const $content = $('
').html(content); + + // Add classes to lists for styling + $content.find('ul').addClass('formatted-list'); + $content.find('ol').addClass('formatted-list numbered'); + + // Add checkboxes to task lists + $content.find('li').each((i, li) => { + const $li = $(li); + const text = $li.text(); + + if (text.startsWith('[ ] ')) { + $li.html(` ${text.substring(4)}`); + $li.addClass('task-item'); + } else if (text.startsWith('[x] ')) { + $li.html(` ${text.substring(4)}`); + $li.addClass('task-item completed'); + } + }); + + return $content.html(); + } + + addMetadata(content, note) { + const metadata = { + lastFormatted: new Date().toISOString(), + wordCount: content.replace(/<[^>]*>/g, '').split(/\s+/).length, + noteId: note.noteId + }; + + const metadataHtml = ` + + `; + + return content + metadataHtml; + } + + async formatJavaScript(note, content) { + // Add JSDoc comments if missing + const lines = content.split('\n'); + const formatted = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect function declarations + if (line.match(/^\s*(async\s+)?function\s+\w+/)) { + if (i === 0 || !lines[i-1].includes('*/')) { + formatted.push('/**'); + formatted.push(' * [Description]'); + formatted.push(' */'); + } + } + + formatted.push(line); + } + + await note.setContent(formatted.join('\n')); + } + + async formatPython(note, content) { + // Add docstrings if missing + const lines = content.split('\n'); + const formatted = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect function definitions + if (line.match(/^\s*def\s+\w+/)) { + formatted.push(line); + if (i + 1 < lines.length && !lines[i + 1].includes('"""')) { + formatted.push(' """[Description]"""'); + } + } else { + formatted.push(line); + } + } + + await note.setContent(formatted.join('\n')); + } + + generateBookTOC(chapters) { + let toc = '

Table of Contents

\n
    \n'; + + for (const chapter of chapters) { + const num = chapter.getLabelValue('chapter'); + const title = chapter.title; + toc += `
  1. ${num}. ${title}
  2. \n`; + } + + toc += '
'; + return toc; + } +} + +// Initialize formatter +const formatter = new NoteFormatter(); + +// Add settings UI +api.addSettingsTab({ + tabId: 'autoFormat', + title: 'Auto-Format', + render: () => { + return ` +
+

Auto-Format Settings

+ + + + + + + +

Format Rules

+ + + +
+ `; + } +}); + +// Save settings function +window.saveFormatSettings = async () => { + const settings = { + enableAutoFormat: document.getElementById('enableAutoFormat').checked, + formatOnSave: document.getElementById('formatOnSave').checked, + addTOC: document.getElementById('addTOC').checked, + rules: JSON.parse(document.getElementById('formatRules').value) + }; + + await api.setOption('autoFormatSettings', JSON.stringify(settings)); + await api.showMessage('Settings saved'); +}; + +console.log('Auto-formatting script loaded'); +``` + +## Advanced Techniques + +### Working with External APIs + +```javascript +// Fetch data from external API +async function fetchExternalData() { + try { + const response = await fetch('https://api.example.com/data', { + headers: { + 'Authorization': `Bearer ${await api.getOption('apiKey')}` + } + }); + + const data = await response.json(); + + // Store in note + const dataNote = await api.createDataNote( + 'root', + 'External Data', + data + ); + + await api.showMessage('Data imported successfully'); + + } catch (error) { + await api.showError(`Failed to fetch data: ${error.message}`); + } +} +``` + +### State Management + +```javascript +// Create a state manager +class StateManager { + constructor() { + this.state = {}; + this.subscribers = []; + this.loadState(); + } + + async loadState() { + const stored = await api.getOption('scriptState'); + if (stored) { + this.state = JSON.parse(stored); + } + } + + async setState(key, value) { + this.state[key] = value; + await this.saveState(); + this.notifySubscribers(key, value); + } + + getState(key) { + return this.state[key]; + } + + async saveState() { + await api.setOption('scriptState', JSON.stringify(this.state)); + } + + subscribe(callback) { + this.subscribers.push(callback); + } + + notifySubscribers(key, value) { + this.subscribers.forEach(cb => cb(key, value)); + } +} + +const state = new StateManager(); +``` + +### Custom UI Components + +```javascript +// Create custom panel +class CustomPanel { + constructor() { + this.createPanel(); + } + + createPanel() { + const $panel = $(` +
+
+

Custom Panel

+ +
+
+ +
+
+ `); + + $('body').append($panel); + + // Add styles + $('