feat(docs): add some more documentation around features that previously weren't documented

This commit is contained in:
perf3ct
2025-08-21 20:29:34 +00:00
parent 4c8da70ef3
commit 5c79760a4a
59 changed files with 26206 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.97.2",
"appVersion": "0.98.0",
"files": [
{
"isClone": false,
@@ -19,6 +19,503 @@
"attachments": [],
"dirFileName": "Developer Guide",
"children": [
{
"isClone": false,
"noteId": "YNX9NN659vF2",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2"
],
"title": "Architecture",
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [],
"format": "markdown",
"attachments": [],
"dirFileName": "Architecture",
"children": [
{
"isClone": false,
"noteId": "yfKVWBgPMQj4",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"yfKVWBgPMQj4"
],
"title": "API-Architecture",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "1HpEf2wiYVmV",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "9GSipWxTZ81I",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "API-Architecture.md",
"attachments": []
},
{
"isClone": false,
"noteId": "9GSipWxTZ81I",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"9GSipWxTZ81I"
],
"title": "Entity-System",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "1HpEf2wiYVmV",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "Entity-System.md",
"attachments": []
},
{
"isClone": false,
"noteId": "QR0jtNmTvYqx",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"QR0jtNmTvYqx"
],
"title": "Monorepo-Structure",
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Monorepo-Structure.md",
"attachments": []
},
{
"isClone": false,
"noteId": "KQT5Xda5Czcl",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"KQT5Xda5Czcl"
],
"title": "README",
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "1HpEf2wiYVmV",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "9GSipWxTZ81I",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "NZV7rSwYe0dV",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "yfKVWBgPMQj4",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "QR0jtNmTvYqx",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "README.md",
"attachments": []
},
{
"isClone": false,
"noteId": "1HpEf2wiYVmV",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"1HpEf2wiYVmV"
],
"title": "Three-Layer-Cache-System",
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "9GSipWxTZ81I",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "Three-Layer-Cache-System.md",
"attachments": []
},
{
"isClone": false,
"noteId": "NZV7rSwYe0dV",
"notePath": [
"jdjRLhLV3TtI",
"YNX9NN659vF2",
"NZV7rSwYe0dV"
],
"title": "Widget-Based-UI-Architecture",
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Widget-Based-UI-Architecture.md",
"attachments": []
}
]
},
{
"isClone": false,
"noteId": "TfoaMQkD75JI",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI"
],
"title": "API Documentation",
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [],
"format": "markdown",
"attachments": [],
"dirFileName": "API Documentation",
"children": [
{
"isClone": false,
"noteId": "tvrq35NqaWce",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI",
"tvrq35NqaWce"
],
"title": "API Client Libraries",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "oB5PbEHAzG9t",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "eDWmn0B42ALV",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "RqCs8jiZGB1g",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "API Client Libraries.md",
"attachments": []
},
{
"isClone": false,
"noteId": "oB5PbEHAzG9t",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI",
"oB5PbEHAzG9t"
],
"title": "ETAPI Complete Guide",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "ETAPI Complete Guide.md",
"attachments": []
},
{
"isClone": false,
"noteId": "4sxya4t9P8AA",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI",
"4sxya4t9P8AA"
],
"title": "Internal API Reference",
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "oB5PbEHAzG9t",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "RqCs8jiZGB1g",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "eDWmn0B42ALV",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "Internal API Reference.md",
"attachments": []
},
{
"isClone": false,
"noteId": "RqCs8jiZGB1g",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI",
"RqCs8jiZGB1g"
],
"title": "Script API Cookbook",
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Script API Cookbook.md",
"attachments": []
},
{
"isClone": false,
"noteId": "eDWmn0B42ALV",
"notePath": [
"jdjRLhLV3TtI",
"TfoaMQkD75JI",
"eDWmn0B42ALV"
],
"title": "WebSocket API",
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [
{
"type": "relation",
"name": "internalLink",
"value": "4sxya4t9P8AA",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "oB5PbEHAzG9t",
"isInheritable": false,
"position": 10
},
{
"type": "relation",
"name": "internalLink",
"value": "tvrq35NqaWce",
"isInheritable": false,
"position": 10
}
],
"format": "markdown",
"dataFileName": "WebSocket API.md",
"attachments": []
}
]
},
{
"isClone": false,
"noteId": "bPMgREWrBYg4",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4"
],
"title": "Plugin Development",
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [],
"format": "markdown",
"attachments": [],
"dirFileName": "Plugin Development",
"children": [
{
"isClone": false,
"noteId": "bZEM5pnOzTVt",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4",
"bZEM5pnOzTVt"
],
"title": "Backend Script Development",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Backend Script Development.md",
"attachments": []
},
{
"isClone": false,
"noteId": "fI5sL6zd3n9K",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4",
"fI5sL6zd3n9K"
],
"title": "Custom Note Type Development",
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Custom Note Type Development.md",
"attachments": []
},
{
"isClone": false,
"noteId": "gRXGO3vGuey9",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4",
"gRXGO3vGuey9"
],
"title": "Custom Widget Development Guide",
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Custom Widget Development Guid.md",
"attachments": []
},
{
"isClone": false,
"noteId": "3ZeRzd0e7Btc",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4",
"3ZeRzd0e7Btc"
],
"title": "Frontend Script Development",
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Frontend Script Development.md",
"attachments": []
},
{
"isClone": false,
"noteId": "dZnOJuAVLKxt",
"notePath": [
"jdjRLhLV3TtI",
"bPMgREWrBYg4",
"dZnOJuAVLKxt"
],
"title": "Theme Development Guide",
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/markdown",
"attributes": [],
"format": "markdown",
"dataFileName": "Theme Development Guide.md",
"attachments": []
}
]
},
{
"isClone": false,
"noteId": "T2W7WCZrYZBU",
@@ -27,7 +524,7 @@
"T2W7WCZrYZBU"
],
"title": "Environment Setup",
"notePosition": 50,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -53,7 +550,7 @@
"cxfTSHIUQtt2"
],
"title": "Project Structure",
"notePosition": 190,
"notePosition": 270,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -139,7 +636,7 @@
"YjerxU7Aii8X"
],
"title": "Troubleshooting",
"notePosition": 200,
"notePosition": 280,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -186,7 +683,7 @@
"wbVIolLKDhe2"
],
"title": "Development and architecture",
"notePosition": 220,
"notePosition": 300,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1728,7 +2225,7 @@
"VHhyVRYK43gI"
],
"title": "Building and deployment",
"notePosition": 230,
"notePosition": 310,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1767,7 +2264,7 @@
"ibAPHul7Efvr"
],
"title": "Old documentation",
"notePosition": 260,
"notePosition": 340,
"prefix": null,
"isExpanded": false,
"type": "text",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,900 @@
# API-Architecture
## API Architecture
Trilium provides multiple API layers for different use cases: Internal API for frontend-backend communication, ETAPI for external integrations, and WebSocket for real-time synchronization. This document details each API layer's design, usage, and best practices.
## API Layers Overview
```
graph TB
subgraph "Client Applications"
WebApp[Web Application]
Desktop[Desktop App]
Mobile[Mobile App]
External[External Apps]
Scripts[User Scripts]
end
subgraph "API Layers"
Internal[Internal API<br/>REST + WebSocket]
ETAPI[ETAPI<br/>External API]
WS[WebSocket<br/>Real-time Sync]
end
subgraph "Backend Services"
Routes[Route Handlers]
Services[Business Logic]
Becca[Becca Cache]
DB[(Database)]
end
WebApp --> Internal
Desktop --> Internal
Mobile --> Internal
External --> ETAPI
Scripts --> ETAPI
Internal --> Routes
ETAPI --> Routes
WS --> Services
Routes --> Services
Services --> Becca
Becca --> DB
style Internal fill:#e3f2fd
style ETAPI fill:#fff3e0
style WS fill:#f3e5f5
```
## Internal API
**Location**: `/apps/server/src/routes/api/`
The Internal API handles communication between Trilium's frontend and backend, providing full access to application functionality.
### Architecture
```typescript
// Route structure
/api/
notes.ts // Note operations
branches.ts // Branch management
attributes.ts // Attribute operations
tree.ts // Tree structure
search.ts // Search functionality
sync.ts // Synchronization
options.ts // Configuration
special.ts // Special operations
```
### Request/Response Pattern
```typescript
// Typical API endpoint structure
router.get('/notes/:noteId', (req, res) => {
const note = becca.getNote(req.params.noteId);
if (!note) {
return res.status(404).json({
error: 'Note not found'
});
}
res.json(note.getPojo());
});
router.put('/notes/:noteId', (req, res) => {
const note = becca.getNoteOrThrow(req.params.noteId);
note.title = req.body.title;
note.content = req.body.content;
note.save();
res.json({ success: true });
});
```
### Key Endpoints
#### Note Operations
```typescript
// Get note with content
GET /api/notes/:noteId
Response: {
noteId: string,
title: string,
type: string,
content: string,
dateCreated: string,
dateModified: string
}
// Update note
PUT /api/notes/:noteId
Body: {
title?: string,
content?: string,
type?: string,
mime?: string
}
// Create note
POST /api/notes/:parentNoteId/children
Body: {
title: string,
type: string,
content?: string,
position?: number
}
// Delete note
DELETE /api/notes/:noteId
```
#### Tree Operations
```typescript
// Get tree structure
GET /api/tree
Query: {
subTreeNoteId?: string,
includeAttributes?: boolean
}
Response: {
notes: FNoteRow[],
branches: FBranchRow[],
attributes: FAttributeRow[]
}
// Move branch
PUT /api/branches/:branchId/move
Body: {
parentNoteId: string,
position: number
}
```
#### Search Operations
```typescript
// Execute search
GET /api/search
Query: {
query: string,
fastSearch?: boolean,
includeArchivedNotes?: boolean,
ancestorNoteId?: string
}
Response: {
results: Array<{
noteId: string,
title: string,
path: string,
score: number
}>
}
```
### Authentication & Security
```typescript
// CSRF protection
app.use(csrfMiddleware);
// Session authentication
router.use((req, res, next) => {
if (!req.session.loggedIn) {
return res.status(401).json({
error: 'Not authenticated'
});
}
next();
});
// Protected note access
router.get('/notes/:noteId', (req, res) => {
const note = becca.getNote(req.params.noteId);
if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) {
return res.status(403).json({
error: 'Protected session required'
});
}
res.json(note.getPojo());
});
```
## ETAPI (External API)
**Location**: `/apps/server/src/etapi/`
ETAPI provides a stable, versioned API for external applications and scripts to interact with Trilium.
### Architecture
```typescript
// ETAPI structure
/etapi/
etapi.openapi.yaml // OpenAPI specification
auth.ts // Authentication
notes.ts // Note endpoints
branches.ts // Branch endpoints
attributes.ts // Attribute endpoints
attachments.ts // Attachment endpoints
special_notes.ts // Special note operations
```
### Authentication
ETAPI uses token-based authentication:
```typescript
// Creating ETAPI token
POST /etapi/auth/login
Body: {
username: string,
password: string
}
Response: {
authToken: string
}
// Using token in requests
GET /etapi/notes/:noteId
Headers: {
Authorization: "authToken"
}
```
### Key Endpoints
#### Note CRUD Operations
```typescript
// Create note
POST /etapi/notes
Body: {
noteId?: string,
parentNoteId: string,
title: string,
type: string,
content?: string,
position?: number
}
// Get note
GET /etapi/notes/:noteId
Response: {
noteId: string,
title: string,
type: string,
mime: string,
isProtected: boolean,
attributes: Array<{
attributeId: string,
type: string,
name: string,
value: string
}>,
parentNoteIds: string[],
childNoteIds: string[],
dateCreated: string,
dateModified: string
}
// Update note content
PUT /etapi/notes/:noteId/content
Body: string | Buffer
Headers: {
"Content-Type": mime-type
}
// Delete note
DELETE /etapi/notes/:noteId
```
#### Attribute Management
```typescript
// Create attribute
POST /etapi/attributes
Body: {
noteId: string,
type: 'label' | 'relation',
name: string,
value: string,
isInheritable?: boolean
}
// Update attribute
PATCH /etapi/attributes/:attributeId
Body: {
value?: string,
isInheritable?: boolean
}
```
#### Search
```typescript
// Search notes
GET /etapi/notes/search
Query: {
search: string,
limit?: number,
orderBy?: string,
orderDirection?: 'asc' | 'desc'
}
Response: {
results: Array<{
noteId: string,
title: string,
// Other note properties
}>
}
```
### Client Libraries
```javascript
// JavaScript client example
class EtapiClient {
constructor(serverUrl, authToken) {
this.serverUrl = serverUrl;
this.authToken = authToken;
}
async getNote(noteId) {
const response = await fetch(
`${this.serverUrl}/etapi/notes/${noteId}`,
{
headers: {
'Authorization': this.authToken
}
}
);
return response.json();
}
async createNote(parentNoteId, title, content) {
const response = await fetch(
`${this.serverUrl}/etapi/notes`,
{
method: 'POST',
headers: {
'Authorization': this.authToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
parentNoteId,
title,
type: 'text',
content
})
}
);
return response.json();
}
}
```
### Python Client Example
```python
import requests
class TriliumETAPI:
def __init__(self, server_url, auth_token):
self.server_url = server_url
self.auth_token = auth_token
self.headers = {'Authorization': auth_token}
def get_note(self, note_id):
response = requests.get(
f"{self.server_url}/etapi/notes/{note_id}",
headers=self.headers
)
return response.json()
def create_note(self, parent_note_id, title, content=""):
response = requests.post(
f"{self.server_url}/etapi/notes",
headers=self.headers,
json={
'parentNoteId': parent_note_id,
'title': title,
'type': 'text',
'content': content
}
)
return response.json()
def search_notes(self, query):
response = requests.get(
f"{self.server_url}/etapi/notes/search",
headers=self.headers,
params={'search': query}
)
return response.json()
```
## WebSocket Real-time Synchronization
**Location**: `/apps/server/src/services/ws.ts`
WebSocket connections provide real-time updates and synchronization between clients.
### Architecture
```typescript
// WebSocket message types
interface WSMessage {
type: string;
data: any;
}
// Common message types
type MessageType =
| 'entity-changes' // Entity updates
| 'sync' // Sync events
| 'note-content-change' // Content updates
| 'refresh-tree' // Tree structure changes
| 'options-changed' // Configuration updates
```
### Connection Management
```typescript
// Client connection
const ws = new WebSocket('wss://server/ws');
ws.on('open', () => {
// Authenticate
ws.send(JSON.stringify({
type: 'auth',
token: sessionToken
}));
});
ws.on('message', (data) => {
const message = JSON.parse(data);
handleWSMessage(message);
});
// Server-side handling
import WebSocket from 'ws';
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws, req) => {
const session = parseSession(req);
if (!session.authenticated) {
ws.close(1008, 'Not authenticated');
return;
}
clients.add(ws);
ws.on('message', (message) => {
handleClientMessage(ws, message);
});
ws.on('close', () => {
clients.delete(ws);
});
});
```
### Message Broadcasting
```typescript
// Broadcast entity changes
function broadcastEntityChanges(changes: EntityChange[]) {
const message = {
type: 'entity-changes',
data: changes
};
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
}
}
// Targeted messages
function sendToClient(clientId: string, message: WSMessage) {
const client = clients.get(clientId);
if (client?.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
}
```
### Real-time Sync Protocol
```typescript
// Entity change notification
{
type: 'entity-changes',
data: [
{
entityName: 'notes',
entityId: 'noteId123',
action: 'update',
entity: { /* note data */ }
}
]
}
// Sync pull request
{
type: 'sync-pull',
data: {
lastSyncId: 12345
}
}
// Sync push
{
type: 'sync-push',
data: {
entities: [ /* changed entities */ ]
}
}
```
### Client-side Handling
```typescript
// Froca WebSocket integration
class WSClient {
constructor() {
this.ws = null;
this.reconnectTimeout = null;
this.connect();
}
connect() {
this.ws = new WebSocket(this.getWSUrl());
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onclose = () => {
// Reconnect with exponential backoff
this.scheduleReconnect();
};
}
handleMessage(message: WSMessage) {
switch (message.type) {
case 'entity-changes':
this.handleEntityChanges(message.data);
break;
case 'refresh-tree':
froca.loadInitialTree();
break;
case 'note-content-change':
this.handleContentChange(message.data);
break;
}
}
handleEntityChanges(changes: EntityChange[]) {
for (const change of changes) {
if (change.entityName === 'notes') {
froca.reloadNotes([change.entityId]);
}
}
}
}
```
## API Security
### Authentication Methods
```typescript
// 1. Session-based (Internal API)
app.use(session({
secret: config.sessionSecret,
resave: false,
saveUninitialized: false
}));
// 2. Token-based (ETAPI)
router.use('/etapi', (req, res, next) => {
const token = req.headers.authorization;
const etapiToken = becca.getEtapiToken(token);
if (!etapiToken || etapiToken.isExpired()) {
return res.status(401).json({
error: 'Invalid or expired token'
});
}
req.etapiToken = etapiToken;
next();
});
// 3. WebSocket authentication
ws.on('connection', (socket) => {
socket.on('auth', (token) => {
if (!validateToken(token)) {
socket.close(1008, 'Invalid token');
}
});
});
```
### Rate Limiting
```typescript
import rateLimit from 'express-rate-limit';
// Global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000 // limit each IP to 1000 requests per windowMs
});
// Strict limit for authentication
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many authentication attempts'
});
app.use('/api', globalLimiter);
app.use('/api/auth', authLimiter);
```
### Input Validation
```typescript
import { body, validationResult } from 'express-validator';
router.post('/api/notes',
body('title').isString().isLength({ min: 1, max: 1000 }),
body('type').isIn(['text', 'code', 'file', 'image']),
body('content').optional().isString(),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
errors: errors.array()
});
}
// Process valid input
}
);
```
## Performance Optimization
### Caching Strategies
```typescript
// Response caching
const cache = new Map();
router.get('/api/notes/:noteId', (req, res) => {
const cacheKey = `note:${req.params.noteId}`;
const cached = cache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return res.json(cached.data);
}
const note = becca.getNote(req.params.noteId);
const data = note.getPojo();
cache.set(cacheKey, {
data,
expires: Date.now() + 60000 // 1 minute
});
res.json(data);
});
```
### Batch Operations
```typescript
// Batch API endpoint
router.post('/api/batch', async (req, res) => {
const operations = req.body.operations;
const results = [];
await sql.transactional(async () => {
for (const op of operations) {
const result = await executeOperation(op);
results.push(result);
}
});
res.json({ results });
});
// Client batch usage
const batch = [
{ method: 'PUT', path: '/notes/1', body: { title: 'Note 1' }},
{ method: 'PUT', path: '/notes/2', body: { title: 'Note 2' }},
{ method: 'POST', path: '/notes/3/attributes', body: { type: 'label', name: 'todo' }}
];
await api.post('/batch', { operations: batch });
```
### Streaming Responses
```typescript
// Stream large data
router.get('/api/export', (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked'
});
const noteStream = createNoteExportStream();
noteStream.on('data', (note) => {
res.write(JSON.stringify(note) + '\n');
});
noteStream.on('end', () => {
res.end();
});
});
```
## Error Handling
### Standard Error Responses
```typescript
// Error response format
interface ErrorResponse {
error: string;
code?: string;
details?: any;
}
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('API Error:', err);
if (err instanceof NotFoundError) {
return res.status(404).json({
error: err.message,
code: 'NOT_FOUND'
});
}
if (err instanceof ValidationError) {
return res.status(400).json({
error: err.message,
code: 'VALIDATION_ERROR',
details: err.details
});
}
// Generic error
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR'
});
});
```
## API Documentation
### OpenAPI/Swagger
```yaml
# etapi.openapi.yaml
openapi: 3.0.0
info:
title: Trilium ETAPI
version: 1.0.0
description: External API for Trilium Notes
paths:
/etapi/notes/{noteId}:
get:
summary: Get note by ID
parameters:
- name: noteId
in: path
required: true
schema:
type: string
responses:
200:
description: Note found
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
404:
description: Note not found
components:
schemas:
Note:
type: object
properties:
noteId:
type: string
title:
type: string
type:
type: string
enum: [text, code, file, image]
```
### API Testing
```typescript
// API test example
describe('Notes API', () => {
it('should create a note', async () => {
const response = await request(app)
.post('/api/notes/root/children')
.send({
title: 'Test Note',
type: 'text',
content: 'Test content'
})
.expect(200);
expect(response.body).toHaveProperty('noteId');
expect(response.body.title).toBe('Test Note');
});
it('should handle errors', async () => {
const response = await request(app)
.get('/api/notes/invalid')
.expect(404);
expect(response.body).toHaveProperty('error');
});
});
```
## Best Practices
### API Design
1. **RESTful conventions**: Use appropriate HTTP methods and status codes
2. **Consistent naming**: Use camelCase for JSON properties
3. **Versioning**: Version the API to maintain compatibility
4. **Documentation**: Keep OpenAPI spec up to date
### Security
1. **Authentication**: Always verify user identity
2. **Authorization**: Check permissions for each operation
3. **Validation**: Validate all input data
4. **Rate limiting**: Prevent abuse with appropriate limits
### Performance
1. **Pagination**: Limit response sizes with pagination
2. **Caching**: Cache frequently accessed data
3. **Batch operations**: Support bulk operations
4. **Async processing**: Use queues for long-running tasks
## Related Documentation
* [Three-Layer Cache System](Three-Layer-Cache-System.md) - Cache architecture
* [Entity System](Entity-System.md) - Data model
* [ETAPI Reference](#root/YCSbHgSqH9PA) - OpenAPI specification

View File

@@ -0,0 +1,614 @@
# Entity-System
## Entity System Architecture
The Entity System forms the core data model of Trilium Notes, providing a flexible and powerful structure for organizing information. This document details the entities, their relationships, and usage patterns.
## Core Entities Overview
```
erDiagram
Note ||--o{ Branch : "parent-child"
Note ||--o{ Attribute : "has"
Note ||--o{ Revision : "history"
Note ||--o{ Attachment : "contains"
Attachment ||--|| Blob : "stores in"
Revision ||--|| Blob : "stores in"
Note }o--o{ Note : "relates via Attribute"
Note {
string noteId PK
string title
string type
string content
boolean isProtected
string dateCreated
string dateModified
}
Branch {
string branchId PK
string noteId FK
string parentNoteId FK
integer notePosition
string prefix
boolean isExpanded
}
Attribute {
string attributeId PK
string noteId FK
string type "label or relation"
string name
string value
integer position
boolean isInheritable
}
Revision {
string revisionId PK
string noteId FK
string title
string type
boolean isProtected
string dateCreated
}
Attachment {
string attachmentId PK
string ownerId FK
string role
string mime
string title
string blobId FK
}
Blob {
string blobId PK
binary content
string dateModified
}
Option {
string name PK
string value
boolean isSynced
}
```
## Entity Definitions
### BNote - Notes with Content and Metadata
**Location**: `/apps/server/src/becca/entities/bnote.ts`
Notes are the fundamental unit of information in Trilium. Each note can contain different types of content and maintain relationships with other notes.
#### Properties
```typescript
class BNote {
noteId: string; // Unique identifier
title: string; // Display title
type: string; // Content type (text, code, file, etc.)
mime: string; // MIME type for content
isProtected: boolean; // Encryption flag
dateCreated: string; // Creation timestamp
dateModified: string; // Last modification
utcDateCreated: string; // UTC creation
utcDateModified: string; // UTC modification
// Relationships
parentBranches: BBranch[]; // Parent connections
children: BBranch[]; // Child connections
attributes: BAttribute[]; // Metadata
// Content
content?: string | Buffer; // Note content (lazy loaded)
// Computed
isDecrypted: boolean; // Decryption status
}
```
#### Note Types
* **text**: Rich text content with HTML formatting
* **code**: Source code with syntax highlighting
* **file**: Binary file attachment
* **image**: Image with preview capabilities
* **search**: Saved search query
* **book**: Container for hierarchical documentation
* **relationMap**: Visual relationship diagram
* **canvas**: Drawing canvas (Excalidraw)
* **mermaid**: Mermaid diagram
* **mindMap**: Mind mapping visualization
* **webView**: Embedded web content
* **noteMap**: Tree visualization
#### Usage Examples
```typescript
// Create a new note
const note = new BNote({
noteId: generateNoteId(),
title: "My Note",
type: "text",
mime: "text/html",
content: "<p>Note content</p>"
});
note.save();
// Get note with content
const note = becca.getNote(noteId);
await note.loadContent();
// Update note
note.title = "Updated Title";
note.save();
// Protect note
note.isProtected = true;
note.encrypt();
note.save();
```
### BBranch - Hierarchical Relationships
**Location**: `/apps/server/src/becca/entities/bbranch.ts`
Branches define the parent-child relationships between notes, allowing a note to have multiple parents (cloning).
#### Properties
```typescript
class BBranch {
branchId: string; // Unique identifier
noteId: string; // Child note ID
parentNoteId: string; // Parent note ID
notePosition: number; // Order among siblings
prefix: string; // Optional prefix label
isExpanded: boolean; // Tree UI state
// Computed
childNote: BNote; // Reference to child
parentNote: BNote; // Reference to parent
}
```
#### Key Features
* **Multiple Parents**: Notes can appear in multiple locations
* **Ordering**: Explicit positioning among siblings
* **Prefixes**: Optional labels for context (e.g., "Chapter 1:")
* **UI State**: Expansion state persisted per branch
#### Usage Examples
```typescript
// Create parent-child relationship
const branch = new BBranch({
noteId: childNote.noteId,
parentNoteId: parentNote.noteId,
notePosition: 10
});
branch.save();
// Clone note to another parent
const cloneBranch = childNote.cloneTo(otherParent.noteId);
// Reorder children
parentNote.sortChildren((a, b) =>
a.title.localeCompare(b.title)
);
// Add prefix
branch.prefix = "Important: ";
branch.save();
```
### BAttribute - Key-Value Metadata
**Location**: `/apps/server/src/becca/entities/battribute.ts`
Attributes provide flexible metadata and relationships between notes.
#### Types
1. **Labels**: Key-value pairs for metadata
2. **Relations**: References to other notes
#### Properties
```typescript
class BAttribute {
attributeId: string; // Unique identifier
noteId: string; // Owning note
type: 'label' | 'relation';
name: string; // Attribute name
value: string; // Value or target noteId
position: number; // Display order
isInheritable: boolean; // Inherited by children
// Computed
note: BNote; // Owner note
targetNote?: BNote; // For relations
}
```
#### Common Patterns
```typescript
// Add label
note.addLabel("status", "active");
note.addLabel("priority", "high");
// Add relation
note.addRelation("template", templateNoteId);
note.addRelation("renderNote", renderNoteId);
// Query by attributes
const todos = becca.findAttributes("label", "todoItem");
const templates = becca.findAttributes("label", "template");
// Inheritable attributes
note.addLabel("workspace", "project", true); // Children inherit
```
#### System Attributes
Special attributes with system behavior:
* `#hidePromotedAttributes`: Hide promoted attributes in UI
* `#readOnly`: Prevent note editing
* `#autoReadOnlyDisabled`: Disable auto read-only
* `#hideChildrenOverview`: Hide children count
* `~template`: Note template relation
* `~renderNote`: Custom rendering relation
### BRevision - Version History
**Location**: `/apps/server/src/becca/entities/brevision.ts`
Revisions provide version history and recovery capabilities.
#### Properties
```typescript
class BRevision {
revisionId: string; // Unique identifier
noteId: string; // Parent note
type: string; // Content type
mime: string; // MIME type
title: string; // Historical title
isProtected: boolean; // Encryption flag
dateCreated: string; // Creation time
utcDateCreated: string; // UTC time
dateModified: string; // Content modification
blobId: string; // Content storage
// Methods
getContent(): string | Buffer;
restore(): void;
}
```
#### Revision Strategy
* Created automatically on significant changes
* Configurable retention period
* Day/week/month/year retention rules
* Protected note revisions are encrypted
#### Usage Examples
```typescript
// Get note revisions
const revisions = note.getRevisions();
// Restore revision
const revision = becca.getRevision(revisionId);
revision.restore();
// Manual revision creation
note.saveRevision();
// Compare revisions
const diff = revision1.getContent() !== revision2.getContent();
```
### BOption - Application Configuration
**Location**: `/apps/server/src/becca/entities/boption.ts`
Options store application and user preferences.
#### Properties
```typescript
class BOption {
name: string; // Option key
value: string; // Option value
isSynced: boolean; // Sync across instances
utcDateModified: string; // Last change
}
```
#### Common Options
```typescript
// Theme settings
setOption("theme", "dark");
// Protected session timeout
setOption("protectedSessionTimeout", "600");
// Sync settings
setOption("syncServerHost", "https://sync.server");
// Note settings
setOption("defaultNoteType", "text");
```
### BAttachment - File Attachments
**Location**: `/apps/server/src/becca/entities/battachment.ts`
Attachments link binary content to notes.
#### Properties
```typescript
class BAttachment {
attachmentId: string; // Unique identifier
ownerId: string; // Parent note ID
role: string; // Attachment role
mime: string; // MIME type
title: string; // Display title
blobId: string; // Content reference
utcDateScheduledForDeletion: string;
// Methods
getContent(): Buffer;
getBlob(): BBlob;
}
```
#### Usage Patterns
```typescript
// Add attachment to note
const attachment = note.addAttachment({
role: "file",
mime: "application/pdf",
title: "document.pdf",
content: buffer
});
// Get attachments
const attachments = note.getAttachments();
// Download attachment
const content = attachment.getContent();
```
## Entity Relationships
### Parent-Child Hierarchy
```typescript
// Single parent
childNote.setParent(parentNote.noteId);
// Multiple parents (cloning)
childNote.cloneTo(parent1.noteId);
childNote.cloneTo(parent2.noteId);
// Get parents
const parents = childNote.getParentNotes();
// Get children
const children = parentNote.getChildNotes();
// Get subtree
const subtree = parentNote.getSubtreeNotes();
```
### Attribute Relationships
```typescript
// Direct relations
note.addRelation("author", authorNote.noteId);
// Bidirectional relations
note1.addRelation("related", note2.noteId);
note2.addRelation("related", note1.noteId);
// Get related notes
const related = note.getRelations("related");
// Get notes relating to this one
const targetRelations = note.getTargetRelations();
```
## Entity Lifecycle
### Creation
```typescript
// Note creation
const note = new BNote({
noteId: generateNoteId(),
title: "New Note",
type: "text"
});
note.save();
// With parent
const child = parentNote.addChild({
title: "Child Note",
type: "text",
content: "Content"
});
```
### Updates
```typescript
// Atomic updates
note.title = "New Title";
note.save();
// Batch updates
sql.transactional(() => {
note1.title = "Title 1";
note1.save();
note2.content = "Content 2";
note2.save();
});
```
### Deletion
```typescript
// Soft delete (move to trash)
note.deleteNote();
// Mark for deletion
note.isDeleted = true;
note.save();
// Permanent deletion (after grace period)
note.eraseNote();
```
## Performance Considerations
### Lazy Loading
```typescript
// Note content loaded on demand
const note = becca.getNote(noteId); // Metadata only
await note.loadContent(); // Load content when needed
// Revisions loaded on demand
const revisions = note.getRevisions(); // Database query
```
### Batch Operations
```typescript
// Efficient bulk loading
const notes = becca.getNotes(noteIds);
// Batch attribute queries
const attributes = sql.getRows(`
SELECT * FROM attributes
WHERE noteId IN (???)
AND name = ?
`, [noteIds, 'label']);
```
### Indexing
```typescript
// Attribute index for fast lookups
const labels = becca.findAttributes("label", "important");
// Branch index for relationship queries
const branch = becca.getBranchFromChildAndParent(childId, parentId);
```
## Best Practices
### Entity Creation
```typescript
// Always use transactions for multiple operations
sql.transactional(() => {
const note = new BNote({...});
note.save();
note.addLabel("status", "draft");
note.addRelation("template", templateId);
});
```
### Entity Updates
```typescript
// Check existence before update
const note = becca.getNote(noteId);
if (note) {
note.title = "Updated";
note.save();
}
// Use proper error handling
try {
const note = becca.getNoteOrThrow(noteId);
note.save();
} catch (e) {
log.error(`Note ${noteId} not found`);
}
```
### Querying
```typescript
// Use indexed queries
const attrs = becca.findAttributes("label", "task");
// Avoid N+1 queries
const noteIds = [...];
const notes = becca.getNotes(noteIds); // Single batch
// Use SQL for complex queries
const results = sql.getRows(`
SELECT n.noteId, n.title, COUNT(b.branchId) as childCount
FROM notes n
LEFT JOIN branches b ON b.parentNoteId = n.noteId
GROUP BY n.noteId
`);
```
## Troubleshooting
### Common Issues
1. **Circular References**
```typescript
// Detect cycles before creating branches
if (!parentNote.hasAncestor(childNote.noteId)) {
childNote.setParent(parentNote.noteId);
}
```
2. **Orphaned Entities**
```typescript
// Find orphaned notes
const orphans = sql.getRows(`
SELECT noteId FROM notes
WHERE noteId != 'root'
AND noteId NOT IN (SELECT noteId FROM branches)
`);
```
3. **Attribute Conflicts**
```typescript
// Handle duplicate attributes
const existing = note.getAttribute("label", "status");
if (existing) {
existing.value = "new value";
existing.save();
} else {
note.addLabel("status", "new value");
}
```
## Related Documentation
* [Three-Layer Cache System](Three-Layer-Cache-System.md) - Cache architecture
* [Database Schema](#root/eZcnGfMUmic0) - Database structure
* [Script API](#root/7Pp4moCrBVzA) - Entity API for scripts

View File

@@ -0,0 +1,612 @@
# Monorepo-Structure
## Monorepo Structure
Trilium is organized as a TypeScript monorepo using NX, facilitating code sharing, consistent tooling, and efficient build processes. This document provides a comprehensive overview of the project structure, build system, and development workflow.
## Project Organization
```
TriliumNext/Trilium/
├── apps/ # Runnable applications
│ ├── client/ # Frontend web application
│ ├── server/ # Node.js backend server
│ ├── desktop/ # Electron desktop application
│ ├── web-clipper/ # Browser extension
│ ├── db-compare/ # Database comparison tool
│ ├── dump-db/ # Database dump utility
│ └── edit-docs/ # Documentation editor
├── packages/ # Shared libraries
│ ├── commons/ # Shared interfaces and utilities
│ ├── ckeditor5/ # Rich text editor
│ ├── codemirror/ # Code editor
│ ├── highlightjs/ # Syntax highlighting
│ ├── ckeditor5-admonition/ # CKEditor plugin
│ ├── ckeditor5-footnotes/ # CKEditor plugin
│ ├── ckeditor5-math/ # CKEditor plugin
│ └── ckeditor5-mermaid/ # CKEditor plugin
├── docs/ # Documentation
├── nx.json # NX workspace configuration
├── package.json # Root package configuration
├── pnpm-workspace.yaml # PNPM workspace configuration
└── tsconfig.base.json # Base TypeScript configuration
```
## Applications
### Client (`/apps/client`)
The frontend application shared by both server and desktop versions.
```
apps/client/
├── src/
│ ├── components/ # Core UI components
│ ├── entities/ # Frontend entities (FNote, FBranch, etc.)
│ ├── services/ # Business logic and API calls
│ ├── widgets/ # UI widgets system
│ │ ├── type_widgets/ # Note type specific widgets
│ │ ├── dialogs/ # Dialog components
│ │ └── panels/ # Panel widgets
│ ├── public/
│ │ ├── fonts/ # Font assets
│ │ ├── images/ # Image assets
│ │ └── libraries/ # Third-party libraries
│ └── desktop.ts # Desktop entry point
├── package.json
├── project.json # NX project configuration
└── vite.config.ts # Vite build configuration
```
#### Key Files
* `desktop.ts` - Main application initialization
* `services/froca.ts` - Frontend cache implementation
* `widgets/basic_widget.ts` - Base widget class
* `services/server.ts` - API communication layer
### Server (`/apps/server`)
The Node.js backend providing API, database, and business logic.
```
apps/server/
├── src/
│ ├── becca/ # Backend cache system
│ │ ├── entities/ # Core entities (BNote, BBranch, etc.)
│ │ └── becca.ts # Cache interface
│ ├── routes/ # Express routes
│ │ ├── api/ # Internal API endpoints
│ │ └── pages/ # HTML page routes
│ ├── etapi/ # External API
│ ├── services/ # Business services
│ ├── share/ # Note sharing functionality
│ │ └── shaca/ # Share cache
│ ├── migrations/ # Database migrations
│ ├── assets/
│ │ ├── db/ # Database schema
│ │ └── doc_notes/ # Documentation notes
│ └── main.ts # Server entry point
├── package.json
├── project.json
└── webpack.config.js # Webpack configuration
```
#### Key Services
* `services/sql.ts` - Database access layer
* `services/sync.ts` - Synchronization logic
* `services/ws.ts` - WebSocket server
* `services/protected_session.ts` - Encryption handling
### Desktop (`/apps/desktop`)
Electron wrapper for the desktop application.
```
apps/desktop/
├── src/
│ ├── main.ts # Electron main process
│ ├── preload.ts # Preload script
│ ├── services/ # Desktop-specific services
│ └── utils/ # Desktop utilities
├── resources/ # Desktop resources (icons, etc.)
├── package.json
└── electron-builder.yml # Electron Builder configuration
```
### Web Clipper (`/apps/web-clipper`)
Browser extension for saving web content to Trilium.
```
apps/web-clipper/
├── src/
│ ├── background.js # Background script
│ ├── content.js # Content script
│ ├── popup/ # Extension popup
│ └── options/ # Extension options
├── manifest.json # Extension manifest
└── package.json
```
## Packages
### Commons (`/packages/commons`)
Shared TypeScript interfaces and utilities used across applications.
```typescript
// packages/commons/src/types.ts
export interface NoteRow {
noteId: string;
title: string;
type: string;
mime: string;
isProtected: boolean;
dateCreated: string;
dateModified: string;
}
export interface BranchRow {
branchId: string;
noteId: string;
parentNoteId: string;
notePosition: number;
prefix: string;
isExpanded: boolean;
}
```
### CKEditor5 (`/packages/ckeditor5`)
Custom CKEditor5 build with Trilium-specific plugins.
```
packages/ckeditor5/
├── src/
│ ├── ckeditor.ts # Editor configuration
│ ├── plugins.ts # Plugin registration
│ └── trilium/ # Custom plugins
├── theme/ # Editor themes
└── package.json
```
#### Custom Plugins
* **Admonition**: Note boxes with icons
* **Footnotes**: Reference footnotes
* **Math**: LaTeX equation rendering
* **Mermaid**: Diagram integration
### CodeMirror (`/packages/codemirror`)
Code editor customizations for the code note type.
```typescript
// packages/codemirror/src/index.ts
export function createCodeMirror(element: HTMLElement, options: CodeMirrorOptions) {
return CodeMirror(element, {
...defaultOptions,
...options,
// Trilium-specific customizations
});
}
```
## Build System
### NX Configuration
**`nx.json`**
```json
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"],
"parallel": 3
}
}
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"cache": true
}
}
}
```
### Project Configuration
Each application and package has a `project.json` defining its targets:
```json
{
"name": "server",
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"outputPath": "dist/apps/server",
"main": "apps/server/src/main.ts",
"tsConfig": "apps/server/tsconfig.app.json"
}
},
"serve": {
"executor": "@nx/node:node",
"options": {
"buildTarget": "server:build"
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/server/jest.config.ts"
}
}
}
}
```
### Build Commands
```sh
# Build specific project
pnpm nx build server
pnpm nx build client
# Build all projects
pnpm nx run-many --target=build --all
# Build with dependencies
pnpm nx build server --with-deps
# Production build
pnpm nx build server --configuration=production
```
## Development Workflow
### Initial Setup
```sh
# Install dependencies
pnpm install
# Enable corepack for pnpm
corepack enable
# Build all packages
pnpm nx run-many --target=build --all
```
### Development Commands
```sh
# Start development server
pnpm run server:start
# or
pnpm nx run server:serve
# Start desktop app
pnpm nx run desktop:serve
# Run client dev server
pnpm nx run client:serve
# Watch mode for packages
pnpm nx run commons:build --watch
```
### Testing
```sh
# Run all tests
pnpm test:all
# Run tests for specific project
pnpm nx test server
pnpm nx test client
# Run tests in watch mode
pnpm nx test server --watch
# Generate coverage
pnpm nx test server --coverage
```
### Linting and Type Checking
```sh
# Lint specific project
pnpm nx lint server
# Type check
pnpm nx run server:typecheck
# Lint all projects
pnpm nx run-many --target=lint --all
# Fix lint issues
pnpm nx lint server --fix
```
## Dependency Management
### Package Dependencies
Dependencies are managed at both root and project levels:
```json
// Root package.json - shared dev dependencies
{
"devDependencies": {
"@nx/workspace": "^17.0.0",
"typescript": "^5.0.0",
"eslint": "^8.0.0"
}
}
// Project package.json - project-specific dependencies
{
"dependencies": {
"express": "^4.18.0",
"better-sqlite3": "^9.0.0"
}
}
```
### Adding Dependencies
```sh
# Add to root
pnpm add -D typescript
# Add to specific project
pnpm add express --filter server
# Add to multiple projects
pnpm add lodash --filter server --filter client
```
### Workspace References
Internal packages are referenced using workspace protocol:
```json
{
"dependencies": {
"@triliumnext/commons": "workspace:*"
}
}
```
## TypeScript Configuration
### Base Configuration
**`tsconfig.base.json`**
```json
{
"compilerOptions": {
"rootDir": ".",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
"@triliumnext/commons": ["packages/commons/src/index.ts"]
}
}
}
```
### Project-Specific Configuration
```json
// apps/server/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["**/*.spec.ts"]
}
```
## Build Optimization
### NX Cloud
```sh
# Enable NX Cloud for distributed caching
pnpm nx connect-to-nx-cloud
```
### Affected Commands
```sh
# Build only affected projects
pnpm nx affected:build --base=main
# Test only affected projects
pnpm nx affected:test --base=main
# Lint only affected projects
pnpm nx affected:lint --base=main
```
### Build Caching
NX caches build outputs to speed up subsequent builds:
```sh
# Clear cache
pnpm nx reset
# Run with cache disabled
pnpm nx build server --skip-nx-cache
# See cache statistics
pnpm nx report
```
## Production Builds
### Building for Production
```sh
# Build server for production
pnpm nx build server --configuration=production
# Build client with optimization
pnpm nx build client --configuration=production
# Build desktop app
pnpm nx build desktop --configuration=production
pnpm electron:build # Creates distributables
```
### Docker Build
```dockerfile
# Multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm nx build server --configuration=production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist/apps/server ./
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "main.js"]
```
## Continuous Integration
### GitHub Actions Example
```yaml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm nx affected:lint --base=origin/main
- run: pnpm nx affected:test --base=origin/main
- run: pnpm nx affected:build --base=origin/main
```
## Troubleshooting
### Common Issues
1. **Build Cache Issues**
```sh
# Clear NX cache
pnpm nx reset
# Clear node_modules and reinstall
rm -rf node_modules
pnpm install
```
2. **Dependency Version Conflicts**
```sh
# Check for duplicate packages
pnpm list --depth=0
# Update all dependencies
pnpm update --recursive
```
3. **TypeScript Path Resolution**
```sh
# Verify TypeScript paths
pnpm nx run server:typecheck --traceResolution
```
### Debug Commands
```sh
# Show project graph
pnpm nx graph
# Show project dependencies
pnpm nx print-affected --type=app --select=projects
# Verbose output
pnpm nx build server --verbose
# Profile build performance
pnpm nx build server --profile
```
## Best Practices
### Project Structure
1. **Keep packages focused**: Each package should have a single, clear purpose
2. **Minimize circular dependencies**: Use dependency graph to identify issues
3. **Share common code**: Extract shared logic to packages/commons
### Development
1. **Use NX generators**: Generate consistent code structure
2. **Leverage caching**: Don't skip-nx-cache unless debugging
3. **Run affected commands**: Save time by only building/testing changed code
### Testing
1. **Colocate tests**: Keep test files next to source files
2. **Use workspace scripts**: Define common scripts in root package.json
3. **Parallel execution**: Use `--parallel` flag for faster execution
## Related Documentation
* [Environment Setup](#root/tFVKyUp99QEc) - Development environment setup
* [Project Structure](#root/NurKhC1Zq1CN) - Detailed project structure
* [Build Information](#root/m5GSI5gIyqs5) - Build details

View File

@@ -0,0 +1,97 @@
# README
## Trilium Architecture Documentation
This comprehensive guide documents the architecture of Trilium Notes, providing developers with detailed information about the system's core components, data flow, and design patterns.
## Table of Contents
1. [Three-Layer Cache System](Three-Layer-Cache-System.md)
2. [Entity System](Entity-System.md)
3. [Widget-Based UI Architecture](Widget-Based-UI-Architecture.md)
4. [API Architecture](API-Architecture.md)
5. [Monorepo Structure](Monorepo-Structure.md)
## Overview
Trilium Notes is built as a TypeScript monorepo using NX, featuring a sophisticated architecture that balances performance, flexibility, and maintainability. The system is designed around several key architectural patterns:
* **Three-layer caching system** for optimal performance across backend, frontend, and shared content
* **Entity-based data model** supporting hierarchical note structures with multiple parent relationships
* **Widget-based UI architecture** enabling modular and extensible interface components
* **Multiple API layers** for internal operations, external integrations, and real-time synchronization
* **Monorepo structure** facilitating code sharing and consistent development patterns
## Quick Start for Developers
If you're new to Trilium development, start with these sections:
1. [Monorepo Structure](Monorepo-Structure.md) - Understand the project organization
2. [Entity System](Entity-System.md) - Learn about the core data model
3. [Three-Layer Cache System](Three-Layer-Cache-System.md) - Understand data flow and caching
For UI development, refer to:
* [Widget-Based UI Architecture](Widget-Based-UI-Architecture.md)
For API integration, see:
* [API Architecture](API-Architecture.md)
## Architecture Principles
### Performance First
* Lazy loading of note content
* Efficient caching at multiple layers
* Optimized database queries with prepared statements
### Flexibility
* Support for multiple note types
* Extensible through scripting
* Plugin architecture for UI widgets
### Data Integrity
* Transactional database operations
* Revision history for all changes
* Synchronization conflict resolution
### Security
* Per-note encryption
* Protected sessions
* API authentication tokens
## Development Workflow
1. **Setup Development Environment**
```sh
pnpm install
pnpm run server:start
```
2. **Make Changes**
* Backend changes in `apps/server/src/`
* Frontend changes in `apps/client/src/`
* Shared code in `packages/`
3. **Test Your Changes**
```sh
pnpm test:all
pnpm nx run <project>:lint
```
4. **Build for Production**
```sh
pnpm nx build server
pnpm nx build client
```
## Further Reading
* [Development Environment Setup](#root/tFVKyUp99QEc)
* [Adding a New Note Type](#root/6aV1LKciq0CF)
* [Database Schema](#root/eZcnGfMUmic0)
* [Script API Documentation](#root/7Pp4moCrBVzA)

View File

@@ -0,0 +1,374 @@
# Three-Layer-Cache-System
## Three-Layer Cache System Architecture
Trilium implements a sophisticated three-layer caching system to optimize performance and reduce database load. This architecture ensures fast access to frequently used data while maintaining consistency across different application contexts.
## Overview
The three cache layers are:
1. **Becca** (Backend Cache) - Server-side entity cache
2. **Froca** (Frontend Cache) - Client-side mirror of backend data
3. **Shaca** (Share Cache) - Optimized cache for shared/published notes
```
graph TB
subgraph "Database Layer"
DB[(SQLite Database)]
end
subgraph "Backend Layer"
Becca[Becca Cache<br/>Backend Cache]
API[API Layer]
end
subgraph "Frontend Layer"
Froca[Froca Cache<br/>Frontend Cache]
UI[UI Components]
end
subgraph "Share Layer"
Shaca[Shaca Cache<br/>Share Cache]
Share[Public Share Interface]
end
DB <--> Becca
Becca <--> API
API <--> Froca
Froca <--> UI
DB <--> Shaca
Shaca <--> Share
style Becca fill:#e1f5fe
style Froca fill:#fff3e0
style Shaca fill:#f3e5f5
```
## Becca (Backend Cache)
**Location**: `/apps/server/src/becca/`
Becca is the authoritative cache layer that maintains all notes, branches, attributes, and options in server memory.
### Key Components
#### Becca Interface (`becca-interface.ts`)
```typescript
export default class Becca {
loaded: boolean;
notes: Record<string, BNote>;
branches: Record<string, BBranch>;
childParentToBranch: Record<string, BBranch>;
attributes: Record<string, BAttribute>;
attributeIndex: Record<string, BAttribute[]>;
options: Record<string, BOption>;
etapiTokens: Record<string, BEtapiToken>;
allNoteSetCache: NoteSet | null;
}
```
### Features
* **In-memory storage**: All active entities are kept in memory for fast access
* **Lazy loading**: Related entities (revisions, attachments) loaded on demand
* **Index structures**: Optimized lookups via `childParentToBranch` and `attributeIndex`
* **Cache invalidation**: Automatic cache updates on entity changes
* **Protected note decryption**: On-demand decryption of encrypted content
### Usage Example
```typescript
import becca from "./becca/becca.js";
// Get a note
const note = becca.getNote("noteId");
// Find attributes by type and name
const labels = becca.findAttributes("label", "todoItem");
// Get branch relationships
const branch = becca.getBranchFromChildAndParent(childId, parentId);
```
### Data Flow
1. **Initialization**: Load all notes, branches, and attributes from database
2. **Access**: Direct memory access for cached entities
3. **Updates**: Write-through cache with immediate database persistence
4. **Invalidation**: Automatic cache refresh on entity changes
## Froca (Frontend Cache)
**Location**: `/apps/client/src/services/froca.ts`
Froca is the frontend mirror of Becca, maintaining a subset of backend data for client-side operations.
### Key Components
#### Froca Implementation (`froca.ts`)
```typescript
class FrocaImpl implements Froca {
notes: Record<string, FNote>;
branches: Record<string, FBranch>;
attributes: Record<string, FAttribute>;
attachments: Record<string, FAttachment>;
blobPromises: Record<string, Promise<FBlob | null> | null>;
}
```
### Features
* **Lazy loading**: Notes loaded on-demand with their immediate context
* **Subtree loading**: Efficient loading of note hierarchies
* **Real-time updates**: WebSocket synchronization with backend changes
* **Search note support**: Virtual branches for search results
* **Promise-based blob loading**: Asynchronous content loading
### Loading Strategy
```typescript
// Initial load - loads root and immediate children
await froca.loadInitialTree();
// Load subtree on demand
const note = await froca.loadSubTree(noteId);
// Reload specific notes
await froca.reloadNotes([noteId1, noteId2]);
```
### Synchronization
Froca maintains consistency with Becca through:
1. **Initial sync**: Load essential tree structure on startup
2. **On-demand loading**: Fetch notes as needed
3. **WebSocket updates**: Real-time push of changes from backend
4. **Batch reloading**: Efficient refresh of multiple notes
## Shaca (Share Cache)
**Location**: `/apps/server/src/share/shaca/`
Shaca is a specialized cache for publicly shared notes, optimized for read-only access.
### Key Components
#### Shaca Interface (`shaca-interface.ts`)
```typescript
export default class Shaca {
notes: Record<string, SNote>;
branches: Record<string, SBranch>;
childParentToBranch: Record<string, SBranch>;
attributes: Record<string, SAttribute>;
attachments: Record<string, SAttachment>;
aliasToNote: Record<string, SNote>;
shareRootNote: SNote | null;
shareIndexEnabled: boolean;
}
```
### Features
* **Read-only optimization**: Streamlined for public access
* **Alias support**: URL-friendly note access via aliases
* **Share index**: Optional indexing of all shared subtrees
* **Minimal memory footprint**: Only shared content cached
* **Security isolation**: Separate from main application cache
### Usage Patterns
```typescript
// Get shared note by ID
const note = shaca.getNote(noteId);
// Access via alias
const aliasedNote = shaca.aliasToNote[alias];
// Check if note is shared
const isShared = shaca.hasNote(noteId);
```
## Cache Interaction and Data Flow
### 1\. Create/Update Flow
```
sequenceDiagram
participant Client
participant Froca
participant API
participant Becca
participant DB
Client->>API: Update Note
API->>Becca: Update Cache
Becca->>DB: Persist Change
Becca->>API: Confirm
API->>Froca: Push Update (WebSocket)
Froca->>Client: Update UI
```
### 2\. Read Flow
```
sequenceDiagram
participant Client
participant Froca
participant API
participant Becca
Client->>Froca: Request Note
alt Note in Cache
Froca->>Client: Return Cached Note
else Note not in Cache
Froca->>API: Fetch Note
API->>Becca: Get Note
Becca->>API: Return Note
API->>Froca: Send Note Data
Froca->>Froca: Cache Note
Froca->>Client: Return Note
end
```
### 3\. Share Access Flow
```
sequenceDiagram
participant Browser
participant ShareUI
participant Shaca
participant DB
Browser->>ShareUI: Access Shared URL
ShareUI->>Shaca: Get Shared Note
alt Note in Cache
Shaca->>ShareUI: Return Cached
else Not in Cache
Shaca->>DB: Load Shared Tree
DB->>Shaca: Return Data
Shaca->>Shaca: Build Cache
Shaca->>ShareUI: Return Note
end
ShareUI->>Browser: Render Content
```
## Performance Considerations
### Memory Management
* **Becca**: Keeps entire note tree in memory (~100-500MB for typical use)
* **Froca**: Loads notes on-demand, automatic cleanup of unused notes
* **Shaca**: Minimal footprint, only shared content
### Cache Warming
* **Becca**: Full load on server startup
* **Froca**: Progressive loading based on user navigation
* **Shaca**: Lazy loading with configurable index
### Optimization Strategies
1. **Attribute Indexing**: Pre-built indexes for fast attribute queries
2. **Batch Operations**: Group updates to minimize round trips
3. **Partial Loading**: Load only required fields for lists
4. **WebSocket Compression**: Compressed real-time updates
## Best Practices
### When to Use Each Cache
**Use Becca when**:
* Implementing server-side business logic
* Performing bulk operations
* Handling synchronization
* Managing protected notes
**Use Froca when**:
* Building UI components
* Handling user interactions
* Displaying note content
* Managing client state
**Use Shaca when**:
* Serving public content
* Building share pages
* Implementing read-only access
* Creating public APIs
### Cache Invalidation
```typescript
// Becca - automatic on entity save
note.save(); // Cache updated automatically
// Froca - manual reload when needed
await froca.reloadNotes([noteId]);
// Shaca - rebuild on share changes
shaca.reset();
shaca.load();
```
### Error Handling
```typescript
// Becca - throw on missing required entities
const note = becca.getNoteOrThrow(noteId); // throws NotFoundError
// Froca - graceful degradation
const note = await froca.getNote(noteId);
if (!note) {
// Handle missing note
}
// Shaca - check existence first
if (shaca.hasNote(noteId)) {
const note = shaca.getNote(noteId);
}
```
## Troubleshooting
### Common Issues
1. **Cache Inconsistency**
* Symptom: UI shows outdated data
* Solution: Force reload with `froca.reloadNotes()`
2. **Memory Growth**
* Symptom: Server memory usage increases
* Solution: Check for memory leaks in custom scripts
3. **Slow Initial Load**
* Symptom: Long startup time
* Solution: Optimize database queries, add indexes
### Debug Commands
```javascript
// Check cache sizes
console.log('Becca notes:', Object.keys(becca.notes).length);
console.log('Froca notes:', Object.keys(froca.notes).length);
console.log('Shaca notes:', Object.keys(shaca.notes).length);
// Force cache refresh
await froca.loadInitialTree();
// Clear and reload Shaca
shaca.reset();
await shaca.load();
```
## Related Documentation
* [Entity System](Entity-System.md) - Detailed entity documentation
* [Database Schema](#root/eZcnGfMUmic0) - Database structure
* [WebSocket Synchronization](#root/UBgXVlHv3d66) - Real-time updates

View File

@@ -0,0 +1,514 @@
# Widget-Based-UI-Architecture
## Widget-Based UI Architecture
Trilium's user interface is built on a widget system where each UI element is a self-contained component. This approach makes the interface modular, allowing developers to create custom widgets and extend the application's functionality.
## Understanding Widgets
Widgets in Trilium form a hierarchy, with each level adding specific capabilities. At the base, every widget is a Component that can have children and communicate with other widgets. The BasicWidget layer adds DOM manipulation, while NoteContextAwareWidget provides automatic updates when notes change.
## Core Widget Classes
### Component (Base Class)
**Location**: `/apps/client/src/components/component.js`
Every UI element in Trilium inherits from Component, which provides the foundation for parent-child relationships and event communication between widgets.
### BasicWidget
**Location**: `/apps/client/src/widgets/basic_widget.ts`
BasicWidget extends Component with DOM manipulation capabilities. It provides a fluent API for styling and composing UI elements:
```typescript
class MyWidget extends BasicWidget {
doRender() {
this.$widget = $('<div class="my-widget">');
this.$widget.append($('<h3>').text('Title'));
return this.$widget;
}
}
// Compose widgets using method chaining
const container = new FlexContainer('column')
.id('main-container')
.css('padding', '10px')
.child(new MyWidget());
```
The chaining API makes it easy to build complex UIs declaratively. Methods like `css()`, `class()`, and `child()` return the widget itself, allowing you to chain multiple operations together.
### NoteContextAwareWidget
**Location**: `/apps/client/src/widgets/note_context_aware_widget.ts`
Widgets that need to react to note changes extend NoteContextAwareWidget. This class automatically calls your widget's methods when the active note changes, making it easy to keep the UI synchronized with the current note.
```typescript
class MyNoteWidget extends NoteContextAwareWidget {
async refreshWithNote(note) {
// Automatically called when note changes
this.$widget.find('.title').text(note.title);
}
}
```
Key lifecycle methods:
* `refreshWithNote(note)` - Called when the active note changes
* `noteSwitched()` - Called after switching to a different note
* `noteTypeMimeChanged()` - Called when the note's type changes
The widget automatically maintains references to the current note, making it simple to build note-aware UI components.
### RightPanelWidget
**Location**: `/apps/client/src/widgets/right_panel_widget.ts`
Widgets in the right sidebar extend RightPanelWidget. These widgets appear as collapsible panels and can show note-specific information or tools.
```typescript
class InfoWidget extends RightPanelWidget {
getTitle() { return "Note Info"; }
getIcon() { return "info"; }
async doRenderBody() {
return $('<div class="info-widget">');
}
async refreshWithNote(note: FNote) {
this.$body.find('.created').text(`Created: ${note.dateCreated}`);
this.$body.find('.modified').text(`Modified: ${note.dateModified}`);
const wordCount = this.calculateWordCount(await note.getContent());
this.$body.find('.word-count').text(`Words: ${wordCount}`);
}
}
```
## Type-Specific Widgets
**Location**: `/apps/client/src/widgets/type_widgets/`
Each note type has a specialized widget for rendering and editing.
### TypeWidget Interface
```typescript
abstract class TypeWidget extends NoteContextAwareWidget {
abstract static getType(): string;
// Content management
async getContent(): Promise<string>;
async saveContent(content: string): Promise<void>;
// Focus management
async focus(): Promise<void>;
async blur(): Promise<void>;
// Cleanup
async cleanup(): Promise<void>;
}
```
### Common Type Widgets
#### TextTypeWidget
```typescript
class TextTypeWidget extends TypeWidget {
static getType() { return 'text'; }
private textEditor: TextEditor;
async doRender() {
const $editor = $('<div class="ck-editor">');
this.textEditor = await TextEditor.create($editor[0], {
noteId: this.noteId,
content: await this.note.getContent()
});
return $editor;
}
async getContent() {
return this.textEditor.getData();
}
}
```
#### CodeTypeWidget
```typescript
class CodeTypeWidget extends TypeWidget {
static getType() { return 'code'; }
private codeMirror: CodeMirror;
async doRender() {
const $container = $('<div class="code-editor">');
this.codeMirror = CodeMirror($container[0], {
value: await this.note.getContent(),
mode: this.note.mime,
theme: 'default',
lineNumbers: true
});
return $container;
}
}
```
## Widget Composition
### Container Widgets
```typescript
// Flexible container layouts
class FlexContainer extends BasicWidget {
constructor(private direction: 'row' | 'column') {
super();
}
doRender() {
this.$widget = $('<div class="flex-container">')
.css('display', 'flex')
.css('flex-direction', this.direction);
for (const child of this.children) {
this.$widget.append(child.render());
}
return this.$widget;
}
}
// Tab container
class TabContainer extends BasicWidget {
private tabs: Array<{title: string, widget: BasicWidget}> = [];
addTab(title: string, widget: BasicWidget) {
this.tabs.push({title, widget});
this.child(widget);
return this;
}
doRender() {
// Render tab headers and content panels
}
}
```
### Composite Widgets
```typescript
class NoteEditorWidget extends NoteContextAwareWidget {
private typeWidget: TypeWidget;
private titleWidget: NoteTitleWidget;
private toolbarWidget: NoteToolbarWidget;
constructor() {
super();
this.child(
this.toolbarWidget = new NoteToolbarWidget(),
this.titleWidget = new NoteTitleWidget(),
// Type widget added dynamically
);
}
async refreshWithNote(note: FNote) {
// Remove old type widget
if (this.typeWidget) {
this.typeWidget.remove();
}
// Add appropriate type widget
const WidgetClass = typeWidgetService.getWidgetClass(note.type);
this.typeWidget = new WidgetClass();
this.child(this.typeWidget);
await this.typeWidget.refresh();
}
}
```
## Widget Communication
### Event System
```typescript
// Publishing events
class PublisherWidget extends BasicWidget {
async handleClick() {
// Local event
this.trigger('itemSelected', { itemId: '123' });
// Global event
appContext.triggerEvent('noteChanged', { noteId: this.noteId });
}
}
// Subscribing to events
class SubscriberWidget extends BasicWidget {
constructor() {
super();
// Local event subscription
this.on('itemSelected', (event) => {
console.log('Item selected:', event.itemId);
});
// Global event subscription
appContext.addEventListener('noteChanged', (event) => {
this.handleNoteChange(event.noteId);
});
}
}
```
### Command System
```typescript
// Registering commands
class CommandWidget extends BasicWidget {
constructor() {
super();
this.bindCommand('saveNote', () => this.saveNote());
this.bindCommand('deleteNote', () => this.deleteNote());
}
getCommands() {
return [
{
command: 'myWidget:doAction',
handler: () => this.doAction(),
hotkey: 'ctrl+shift+a'
}
];
}
}
```
## Custom Widget Development
### Creating Custom Widgets
```typescript
// 1. Define widget class
class TaskListWidget extends NoteContextAwareWidget {
doRender() {
this.$widget = $('<div class="task-list-widget">');
this.$list = $('<ul>').appendTo(this.$widget);
return this.$widget;
}
async refreshWithNote(note: FNote) {
const tasks = await this.loadTasks(note);
this.$list.empty();
for (const task of tasks) {
$('<li>')
.text(task.title)
.toggleClass('completed', task.completed)
.appendTo(this.$list);
}
}
private async loadTasks(note: FNote) {
// Load task data from note attributes
const taskLabels = note.getLabels('task');
return taskLabels.map(label => JSON.parse(label.value));
}
}
// 2. Register widget
api.addWidget(TaskListWidget);
```
### Widget Lifecycle
```typescript
class LifecycleWidget extends NoteContextAwareWidget {
// 1. Construction
constructor() {
super();
console.log('Widget constructed');
}
// 2. Initial render
doRender() {
console.log('Initial render');
return $('<div>');
}
// 3. Context initialization
async refresh() {
console.log('Context refresh');
await super.refresh();
}
// 4. Note updates
async refreshWithNote(note: FNote) {
console.log('Note refresh:', note.noteId);
}
// 5. Cleanup
async cleanup() {
console.log('Widget cleanup');
// Release resources
}
}
```
## Performance Optimization
### Lazy Loading
```typescript
class LazyWidget extends BasicWidget {
private contentLoaded = false;
async becomeVisible() {
if (!this.contentLoaded) {
await this.loadContent();
this.contentLoaded = true;
}
}
private async loadContent() {
// Heavy content loading
const data = await server.get('expensive-data');
this.renderContent(data);
}
}
```
### Debouncing Updates
```typescript
class DebouncedWidget extends NoteContextAwareWidget {
private refreshDebounced = utils.debounce(
() => this.doRefresh(),
500
);
async refreshWithNote(note: FNote) {
// Debounce rapid updates
this.refreshDebounced();
}
private async doRefresh() {
// Actual refresh logic
}
}
```
### Virtual Scrolling
```typescript
class VirtualListWidget extends BasicWidget {
private visibleItems: any[] = [];
renderVisibleItems(scrollTop: number) {
const itemHeight = 30;
const containerHeight = this.$widget.height();
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
this.visibleItems = this.allItems.slice(startIndex, endIndex);
this.renderItems();
}
}
```
## Best Practices
### Widget Design
1. **Single Responsibility**: Each widget should have one clear purpose
2. **Composition over Inheritance**: Use composition for complex UIs
3. **Lazy Initialization**: Load resources only when needed
4. **Event Cleanup**: Remove event listeners in cleanup()
### State Management
```typescript
class StatefulWidget extends NoteContextAwareWidget {
private state = {
isExpanded: false,
selectedItems: new Set<string>()
};
setState(updates: Partial<typeof this.state>) {
Object.assign(this.state, updates);
this.renderState();
}
private renderState() {
this.$widget.toggleClass('expanded', this.state.isExpanded);
// Update DOM based on state
}
}
```
### Error Handling
```typescript
class ResilientWidget extends BasicWidget {
async refreshWithNote(note: FNote) {
try {
await this.loadData(note);
} catch (error) {
this.showError('Failed to load data');
console.error('Widget error:', error);
}
}
private showError(message: string) {
this.$widget.html(`
<div class="alert alert-danger">
${message}
</div>
`);
}
}
```
## Testing Widgets
```typescript
// Widget test example
describe('TaskListWidget', () => {
let widget: TaskListWidget;
let note: FNote;
beforeEach(() => {
widget = new TaskListWidget();
note = createMockNote({
noteId: 'test123',
attributes: [
{ type: 'label', name: 'task', value: '{"title":"Task 1"}' }
]
});
});
it('should render tasks', async () => {
await widget.refreshWithNote(note);
const tasks = widget.$widget.find('li');
expect(tasks.length).toBe(1);
expect(tasks.text()).toBe('Task 1');
});
});
```
## Related Documentation
* [Frontend Basics](#root/8Cc2LnZFcF3K) - Frontend scripting guide
* [Custom Widgets](#root/VAerafq2qHO1) - Creating custom widgets
* [Script API](#root/7Pp4moCrBVzA) - Widget API reference

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 = $('<div>Hello from my widget!</div>');
}
}
```
### Note-Aware Widget
To make your widget respond to note changes, extend NoteContextAwareWidget:
```javascript
class NoteInfoWidget extends NoteContextAwareWidget {
doRender() {
this.$widget = $('<div class="note-info"></div>');
}
async refreshWithNote(note) {
this.$widget.html(`
<h3>${note.title}</h3>
<p>Type: ${note.type}</p>
`);
}
}
```
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('<div class="stats">Loading...</div>');
}
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 = $('<div>');
this.css('padding', '10px')
.css('background-color', '#f0f0f0')
.css('border-radius', '4px');
}
}
```
### CSS Classes
```typescript
class ClassedWidget extends BasicWidget {
doRender() {
this.$widget = $('<div>');
this.class('custom-widget')
.class('bordered');
}
}
```
### CSS Blocks
```typescript
class CSSBlockWidget extends BasicWidget {
doRender() {
this.$widget = $('<div class="my-widget">Content</div>');
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 = $('<div>');
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('<div class="error">Failed to load</div>');
}
}
}
```
### Development Tools
```typescript
class DevWidget extends BasicWidget {
doRender() {
this.$widget = $('<div>');
// Add debug information in development
if (window.glob.isDev) {
this.$widget.attr('data-debug', 'true');
this.$widget.append(`
<div class="debug-info">
Component ID: ${this.componentId}
Position: ${this.position}
</div>
`);
}
}
}
```
## 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(`
<div class="note-statistics">
<div class="stat-group">
<h5>Content</h5>
<div class="stat-item">
<span class="stat-label">Words:</span>
<span class="stat-value" data-stat="words">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Characters:</span>
<span class="stat-value" data-stat="characters">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Paragraphs:</span>
<span class="stat-value" data-stat="paragraphs">0</span>
</div>
</div>
<div class="stat-group">
<h5>Reading</h5>
<div class="stat-item">
<span class="stat-label">Reading time:</span>
<span class="stat-value" data-stat="readingTime">0 min</span>
</div>
</div>
<div class="stat-group">
<h5>Elements</h5>
<div class="stat-item">
<span class="stat-label">Links:</span>
<span class="stat-value" data-stat="links">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Images:</span>
<span class="stat-value" data-stat="images">0</span>
</div>
</div>
<div class="stat-actions">
<button class="btn btn-sm refresh-stats">Refresh</button>
<button class="btn btn-sm export-stats">Export</button>
</div>
</div>
`);
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 = $('<div>').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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff