mirror of
https://github.com/zadam/trilium.git
synced 2025-11-01 10:55:55 +01:00
feat(docs): completely redo documentation
This commit is contained in:
899
docs/Developer Guide/Architecture/API-Architecture.md
vendored
Normal file
899
docs/Developer Guide/Architecture/API-Architecture.md
vendored
Normal file
@@ -0,0 +1,899 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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](/apps/server/src/etapi/etapi.openapi.yaml) - OpenAPI specification
|
||||
612
docs/Developer Guide/Architecture/Entity-System.md
vendored
Normal file
612
docs/Developer Guide/Architecture/Entity-System.md
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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](../Development%20and%20architecture/Database/notes.md) - Database structure
|
||||
- [Script API](../../Script%20API/) - Entity API for scripts
|
||||
610
docs/Developer Guide/Architecture/Monorepo-Structure.md
vendored
Normal file
610
docs/Developer Guide/Architecture/Monorepo-Structure.md
vendored
Normal file
@@ -0,0 +1,610 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Enable corepack for pnpm
|
||||
corepack enable
|
||||
|
||||
# Build all packages
|
||||
pnpm nx run-many --target=build --all
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Enable NX Cloud for distributed caching
|
||||
pnpm nx connect-to-nx-cloud
|
||||
```
|
||||
|
||||
### Affected Commands
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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**
|
||||
```bash
|
||||
# Clear NX cache
|
||||
pnpm nx reset
|
||||
|
||||
# Clear node_modules and reinstall
|
||||
rm -rf node_modules
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. **Dependency Version Conflicts**
|
||||
```bash
|
||||
# Check for duplicate packages
|
||||
pnpm list --depth=0
|
||||
|
||||
# Update all dependencies
|
||||
pnpm update --recursive
|
||||
```
|
||||
|
||||
3. **TypeScript Path Resolution**
|
||||
```bash
|
||||
# Verify TypeScript paths
|
||||
pnpm nx run server:typecheck --traceResolution
|
||||
```
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# 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](../Environment%20Setup.md) - Development environment setup
|
||||
- [Project Structure](../Project%20Structure.md) - Detailed project structure
|
||||
- [Build Information](../Development%20and%20architecture/Build%20information.md) - Build details
|
||||
89
docs/Developer Guide/Architecture/README.md
vendored
Normal file
89
docs/Developer Guide/Architecture/README.md
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# 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**
|
||||
```bash
|
||||
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**
|
||||
```bash
|
||||
pnpm test:all
|
||||
pnpm nx run <project>:lint
|
||||
```
|
||||
|
||||
4. **Build for Production**
|
||||
```bash
|
||||
pnpm nx build server
|
||||
pnpm nx build client
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Development Environment Setup](../Environment%20Setup.md)
|
||||
- [Adding a New Note Type](../Development%20and%20architecture/Adding%20a%20new%20note%20type/First%20steps.md)
|
||||
- [Database Schema](../Development%20and%20architecture/Database/notes.md)
|
||||
- [Script API Documentation](../../Script%20API/)
|
||||
369
docs/Developer Guide/Architecture/Three-Layer-Cache-System.md
vendored
Normal file
369
docs/Developer Guide/Architecture/Three-Layer-Cache-System.md
vendored
Normal file
@@ -0,0 +1,369 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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](../Development%20and%20architecture/Database/notes.md) - Database structure
|
||||
- [WebSocket Synchronization](API-Architecture.md#websocket-real-time-synchronization) - Real-time updates
|
||||
635
docs/Developer Guide/Architecture/Widget-Based-UI-Architecture.md
vendored
Normal file
635
docs/Developer Guide/Architecture/Widget-Based-UI-Architecture.md
vendored
Normal file
@@ -0,0 +1,635 @@
|
||||
# Widget-Based UI Architecture
|
||||
|
||||
Trilium's frontend is built on a modular widget system that provides flexibility, reusability, and maintainability. This architecture enables dynamic UI composition and extensibility through custom widgets.
|
||||
|
||||
## Widget System Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Widget Hierarchy"
|
||||
Component[Component<br/>Base Class]
|
||||
BasicWidget[BasicWidget<br/>UI Foundation]
|
||||
NoteContextAware[NoteContextAwareWidget<br/>Note-Aware]
|
||||
RightPanel[RightPanelWidget<br/>Side Panel]
|
||||
TypeWidgets[Type Widgets<br/>Note Type Specific]
|
||||
CustomWidgets[Custom Widgets<br/>User Scripts]
|
||||
end
|
||||
|
||||
Component --> BasicWidget
|
||||
BasicWidget --> NoteContextAware
|
||||
NoteContextAware --> RightPanel
|
||||
NoteContextAware --> TypeWidgets
|
||||
NoteContextAware --> CustomWidgets
|
||||
|
||||
style Component fill:#e8f5e9
|
||||
style BasicWidget fill:#c8e6c9
|
||||
style NoteContextAware fill:#a5d6a7
|
||||
```
|
||||
|
||||
## Core Widget Classes
|
||||
|
||||
### Component (Base Class)
|
||||
|
||||
**Location**: `/apps/client/src/components/component.js`
|
||||
|
||||
The foundational class for all UI components in Trilium.
|
||||
|
||||
```typescript
|
||||
class Component {
|
||||
componentId: string; // Unique identifier
|
||||
children: Component[]; // Child components
|
||||
parent: Component | null; // Parent reference
|
||||
|
||||
async refresh(): Promise<void>;
|
||||
child(...components: Component[]): this;
|
||||
handleEvent(name: string, data: any): void;
|
||||
trigger(name: string, data?: any): void;
|
||||
}
|
||||
```
|
||||
|
||||
### BasicWidget
|
||||
|
||||
**Location**: `/apps/client/src/widgets/basic_widget.ts`
|
||||
|
||||
Base class for all UI widgets, providing DOM manipulation and styling capabilities.
|
||||
|
||||
```typescript
|
||||
export class BasicWidget extends Component {
|
||||
protected $widget: JQuery;
|
||||
private attrs: Record<string, string>;
|
||||
private classes: string[];
|
||||
|
||||
// Chaining methods for declarative UI
|
||||
id(id: string): this;
|
||||
class(className: string): this;
|
||||
css(name: string, value: string): this;
|
||||
contentSized(): this;
|
||||
collapsible(): this;
|
||||
filling(): this;
|
||||
|
||||
// Conditional rendering
|
||||
optChild(condition: boolean, ...components: Component[]): this;
|
||||
optCss(condition: boolean, name: string, value: string): this;
|
||||
|
||||
// Rendering
|
||||
doRender(): JQuery;
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage Example
|
||||
|
||||
```typescript
|
||||
class MyWidget extends BasicWidget {
|
||||
doRender() {
|
||||
this.$widget = $('<div>')
|
||||
.addClass('my-widget')
|
||||
.append($('<h3>').text('Widget Title'));
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
this.$widget.find('h3').text(note.title);
|
||||
}
|
||||
}
|
||||
|
||||
// Composing widgets
|
||||
const container = new FlexContainer('column')
|
||||
.id('main-container')
|
||||
.css('padding', '10px')
|
||||
.filling()
|
||||
.child(
|
||||
new MyWidget(),
|
||||
new ButtonWidget()
|
||||
.title('Click Me')
|
||||
.onClick(() => console.log('Clicked'))
|
||||
);
|
||||
```
|
||||
|
||||
### NoteContextAwareWidget
|
||||
|
||||
**Location**: `/apps/client/src/widgets/note_context_aware_widget.ts`
|
||||
|
||||
Base class for widgets that respond to note context changes.
|
||||
|
||||
```typescript
|
||||
class NoteContextAwareWidget extends BasicWidget {
|
||||
noteContext: NoteContext | null;
|
||||
note: FNote | null;
|
||||
noteId: string | null;
|
||||
notePath: string | null;
|
||||
|
||||
// Lifecycle methods
|
||||
async refresh(): Promise<void>;
|
||||
async refreshWithNote(note: FNote): Promise<void>;
|
||||
async noteSwitched(): Promise<void>;
|
||||
async activeContextChanged(): Promise<void>;
|
||||
|
||||
// Event handlers
|
||||
async noteTypeMimeChanged(): Promise<void>;
|
||||
async frocaReloaded(): Promise<void>;
|
||||
|
||||
// Utility methods
|
||||
isNote(noteId: string): boolean;
|
||||
get isEnabled(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### Context Management
|
||||
|
||||
```typescript
|
||||
class MyNoteWidget extends NoteContextAwareWidget {
|
||||
async refreshWithNote(note: FNote) {
|
||||
// Called when note context changes
|
||||
this.$widget.find('.note-title').text(note.title);
|
||||
this.$widget.find('.note-type').text(note.type);
|
||||
|
||||
// Access note attributes
|
||||
const labels = note.getLabels();
|
||||
const relations = note.getRelations();
|
||||
}
|
||||
|
||||
async noteSwitched() {
|
||||
// Called when user switches to different note
|
||||
console.log(`Switched to note: ${this.noteId}`);
|
||||
}
|
||||
|
||||
async noteTypeMimeChanged() {
|
||||
// React to note type changes
|
||||
if (this.note?.type === 'code') {
|
||||
this.setupCodeHighlighting();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### RightPanelWidget
|
||||
|
||||
**Location**: `/apps/client/src/widgets/right_panel_widget.ts`
|
||||
|
||||
Base class for widgets displayed in the right sidebar panel.
|
||||
|
||||
```typescript
|
||||
abstract class RightPanelWidget extends NoteContextAwareWidget {
|
||||
async doRenderBody(): Promise<JQuery>;
|
||||
getTitle(): string;
|
||||
getIcon(): string;
|
||||
getPosition(): number;
|
||||
|
||||
async isEnabled(): Promise<boolean> {
|
||||
// Override to control visibility
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Creating Right Panel Widgets
|
||||
|
||||
```typescript
|
||||
class InfoWidget extends RightPanelWidget {
|
||||
getTitle() { return "Note Info"; }
|
||||
getIcon() { return "info"; }
|
||||
getPosition() { return 100; }
|
||||
|
||||
async doRenderBody() {
|
||||
return $('<div class="info-widget">')
|
||||
.append($('<div class="created">'))
|
||||
.append($('<div class="modified">'))
|
||||
.append($('<div class="word-count">'));
|
||||
}
|
||||
|
||||
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](../../Scripting/Frontend%20Basics.html) - Frontend scripting guide
|
||||
- [Custom Widgets](../../Scripting/Custom%20Widgets.html) - Creating custom widgets
|
||||
- [Script API](../../Script%20API/) - Widget API reference
|
||||
Reference in New Issue
Block a user