mirror of
https://github.com/zadam/trilium.git
synced 2025-10-29 01:06:36 +01:00
1035 lines
26 KiB
Markdown
Vendored
1035 lines
26 KiB
Markdown
Vendored
# Frontend Script Development Guide
|
||
|
||
This guide covers developing frontend scripts in Trilium Notes. Frontend scripts run in the browser context and can interact with the UI, modify behavior, and create custom functionality.
|
||
|
||
## Prerequisites
|
||
|
||
- JavaScript/TypeScript knowledge
|
||
- Understanding of browser APIs and DOM manipulation
|
||
- Basic knowledge of Trilium's note system
|
||
- Familiarity with async/await patterns
|
||
|
||
## Getting Started
|
||
|
||
### Creating a Frontend Script
|
||
|
||
1. Create a new code note with type "JS Frontend"
|
||
2. Add the `#run=frontendStartup` label to run on startup
|
||
3. Write your JavaScript code
|
||
|
||
```javascript
|
||
// Basic frontend script
|
||
api.addButtonToToolbar({
|
||
title: 'My Custom Button',
|
||
icon: 'bx bx-star',
|
||
action: async () => {
|
||
await api.showMessage('Hello from custom script!');
|
||
}
|
||
});
|
||
```
|
||
|
||
### Script Execution Context
|
||
|
||
Frontend scripts run in the browser with access to:
|
||
- Trilium's Frontend API (`api` global object)
|
||
- Browser APIs (DOM, fetch, localStorage, etc.)
|
||
- jQuery (`$` global)
|
||
- All loaded libraries
|
||
|
||
## Frontend API Reference
|
||
|
||
### Core API Object
|
||
|
||
The `api` object is globally available in all frontend scripts:
|
||
|
||
```javascript
|
||
// Access current note
|
||
const currentNote = api.getActiveContextNote();
|
||
|
||
// Get note by ID
|
||
const note = await api.getNote('noteId123');
|
||
|
||
// Search notes
|
||
const results = await api.searchForNotes('type:text @label=important');
|
||
```
|
||
|
||
### Note Operations
|
||
|
||
#### Reading Notes
|
||
|
||
```javascript
|
||
// Get active note
|
||
const activeNote = api.getActiveContextNote();
|
||
console.log('Current note:', activeNote.title);
|
||
|
||
// Get note by ID
|
||
const note = await api.getNote('noteId123');
|
||
|
||
// Get note content
|
||
const content = await note.getContent();
|
||
|
||
// Get note attributes
|
||
const attributes = note.getAttributes();
|
||
const labels = note.getLabels();
|
||
const relations = note.getRelations();
|
||
|
||
// Get child notes
|
||
const children = await note.getChildNotes();
|
||
|
||
// Get parent notes
|
||
const parents = await note.getParentNotes();
|
||
```
|
||
|
||
#### Creating Notes
|
||
|
||
```javascript
|
||
// Create a simple note
|
||
const newNote = await api.createNote(
|
||
parentNoteId,
|
||
'New Note Title',
|
||
'Note content here'
|
||
);
|
||
|
||
// Create note with options
|
||
const note = await api.createNote(
|
||
parentNoteId,
|
||
'Advanced Note',
|
||
'<p>HTML content</p>',
|
||
{
|
||
type: 'text',
|
||
mime: 'text/html',
|
||
isProtected: false
|
||
}
|
||
);
|
||
|
||
// Create data note for storing JSON
|
||
const dataNote = await api.createDataNote(
|
||
parentNoteId,
|
||
'config',
|
||
{ key: 'value', settings: {} }
|
||
);
|
||
```
|
||
|
||
#### Modifying Notes
|
||
|
||
```javascript
|
||
// Update note title
|
||
await note.setTitle('New Title');
|
||
|
||
// Update note content
|
||
await note.setContent('New content');
|
||
|
||
// Add label
|
||
await note.addLabel('status', 'completed');
|
||
|
||
// Add relation
|
||
await note.addRelation('relatedTo', targetNoteId);
|
||
|
||
// Remove attribute
|
||
await note.removeAttribute(attributeId);
|
||
|
||
// Toggle label
|
||
await note.toggleLabel('archived');
|
||
await note.toggleLabel('priority', 'high');
|
||
```
|
||
|
||
### UI Interaction
|
||
|
||
#### Showing Messages
|
||
|
||
```javascript
|
||
// Simple message
|
||
await api.showMessage('Operation completed');
|
||
|
||
// Error message
|
||
await api.showError('Something went wrong');
|
||
|
||
// Message with duration
|
||
await api.showMessage('Saved!', 3000);
|
||
|
||
// Persistent message
|
||
const toast = await api.showPersistent({
|
||
title: 'Processing',
|
||
message: 'Please wait...',
|
||
icon: 'loader'
|
||
});
|
||
|
||
// Close persistent message
|
||
toast.close();
|
||
```
|
||
|
||
#### Dialogs
|
||
|
||
```javascript
|
||
// Confirmation dialog
|
||
const confirmed = await api.showConfirmDialog({
|
||
title: 'Delete Note?',
|
||
message: 'This action cannot be undone.',
|
||
okButtonLabel: 'Delete',
|
||
cancelButtonLabel: 'Keep'
|
||
});
|
||
|
||
if (confirmed) {
|
||
// Proceed with deletion
|
||
}
|
||
|
||
// Prompt dialog
|
||
const input = await api.showPromptDialog({
|
||
title: 'Enter Name',
|
||
message: 'Please enter a name for the new note:',
|
||
defaultValue: 'Untitled'
|
||
});
|
||
|
||
if (input) {
|
||
await api.createNote(parentId, input, '');
|
||
}
|
||
```
|
||
|
||
### Custom Commands
|
||
|
||
#### Adding Menu Items
|
||
|
||
```javascript
|
||
// Add to note context menu
|
||
api.addContextMenuItemToNotes({
|
||
title: 'Copy Note ID',
|
||
icon: 'bx bx-copy',
|
||
handler: async (note) => {
|
||
await navigator.clipboard.writeText(note.noteId);
|
||
await api.showMessage('Note ID copied');
|
||
}
|
||
});
|
||
|
||
// Add to toolbar
|
||
api.addButtonToToolbar({
|
||
title: 'Quick Action',
|
||
icon: 'bx bx-bolt',
|
||
shortcut: 'ctrl+shift+q',
|
||
action: async () => {
|
||
// Your action here
|
||
}
|
||
});
|
||
```
|
||
|
||
#### Registering Commands
|
||
|
||
```javascript
|
||
// Register a global command
|
||
api.bindGlobalShortcut('ctrl+shift+t', async () => {
|
||
const note = api.getActiveContextNote();
|
||
const timestamp = new Date().toISOString();
|
||
await note.addLabel('lastAccessed', timestamp);
|
||
await api.showMessage('Timestamp added');
|
||
});
|
||
|
||
// Add command palette action
|
||
api.addCommandPaletteItem({
|
||
name: 'Toggle Dark Mode',
|
||
description: 'Switch between light and dark themes',
|
||
action: async () => {
|
||
const currentTheme = await api.getOption('theme');
|
||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||
await api.setOption('theme', newTheme);
|
||
}
|
||
});
|
||
```
|
||
|
||
### Event Handling
|
||
|
||
#### Listening to Events
|
||
|
||
```javascript
|
||
// Note switch event
|
||
api.onNoteChange(async ({ note, previousNote }) => {
|
||
console.log(`Switched from ${previousNote?.title} to ${note.title}`);
|
||
|
||
// Update custom UI
|
||
updateCustomPanel(note);
|
||
});
|
||
|
||
// Content change event
|
||
api.onNoteContentChange(async ({ note }) => {
|
||
console.log(`Content changed for ${note.title}`);
|
||
|
||
// Auto-save to external service
|
||
await syncToExternalService(note);
|
||
});
|
||
|
||
// Attribute change event
|
||
api.onAttributeChange(async ({ note, attribute }) => {
|
||
if (attribute.name === 'status' && attribute.value === 'completed') {
|
||
await note.addLabel('completedDate', new Date().toISOString());
|
||
}
|
||
});
|
||
```
|
||
|
||
#### Custom Events
|
||
|
||
```javascript
|
||
// Trigger custom event
|
||
api.triggerEvent('myCustomEvent', { data: 'value' });
|
||
|
||
// Listen to custom event
|
||
api.onCustomEvent('myCustomEvent', async (data) => {
|
||
console.log('Custom event received:', data);
|
||
});
|
||
```
|
||
|
||
### Working with Widgets
|
||
|
||
```javascript
|
||
// Access widget system
|
||
const widget = api.getWidget('NoteTreeWidget');
|
||
|
||
// Refresh widget
|
||
await widget.refresh();
|
||
|
||
// Create custom widget container
|
||
const container = api.createCustomWidget({
|
||
title: 'My Widget',
|
||
position: 'left',
|
||
render: async () => {
|
||
return `
|
||
<div class="custom-widget">
|
||
<h3>Custom Content</h3>
|
||
<button onclick="handleClick()">Click Me</button>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
```
|
||
|
||
## Complete Example: Auto-Formatting Script
|
||
|
||
Here's a comprehensive example that automatically formats notes based on their type:
|
||
|
||
```javascript
|
||
/**
|
||
* Auto-Formatting Script
|
||
* Automatically formats notes based on their type and content
|
||
*/
|
||
|
||
class NoteFormatter {
|
||
constructor() {
|
||
this.setupEventListeners();
|
||
this.registerCommands();
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Format on note save
|
||
api.onNoteContentChange(async ({ note }) => {
|
||
if (await this.shouldAutoFormat(note)) {
|
||
await this.formatNote(note);
|
||
}
|
||
});
|
||
|
||
// Format when label added
|
||
api.onAttributeChange(async ({ note, attribute }) => {
|
||
if (attribute.type === 'label' &&
|
||
attribute.name === 'autoFormat' &&
|
||
attribute.value === 'true') {
|
||
await this.formatNote(note);
|
||
}
|
||
});
|
||
}
|
||
|
||
registerCommands() {
|
||
// Add toolbar button
|
||
api.addButtonToToolbar({
|
||
title: 'Format Note',
|
||
icon: 'bx bx-text',
|
||
shortcut: 'ctrl+shift+f',
|
||
action: async () => {
|
||
const note = api.getActiveContextNote();
|
||
await this.formatNote(note);
|
||
await api.showMessage('Note formatted');
|
||
}
|
||
});
|
||
|
||
// Add context menu item
|
||
api.addContextMenuItemToNotes({
|
||
title: 'Auto-Format',
|
||
icon: 'bx bx-magic',
|
||
handler: async (note) => {
|
||
await this.formatNote(note);
|
||
}
|
||
});
|
||
}
|
||
|
||
async shouldAutoFormat(note) {
|
||
// Check if note has autoFormat label
|
||
const labels = note.getLabels();
|
||
return labels.some(l => l.name === 'autoFormat' && l.value === 'true');
|
||
}
|
||
|
||
async formatNote(note) {
|
||
const type = note.type;
|
||
|
||
switch (type) {
|
||
case 'text':
|
||
await this.formatTextNote(note);
|
||
break;
|
||
case 'code':
|
||
await this.formatCodeNote(note);
|
||
break;
|
||
case 'book':
|
||
await this.formatBookNote(note);
|
||
break;
|
||
}
|
||
}
|
||
|
||
async formatTextNote(note) {
|
||
let content = await note.getContent();
|
||
|
||
// Apply formatting rules
|
||
content = this.addTableOfContents(content);
|
||
content = this.formatHeadings(content);
|
||
content = this.formatLists(content);
|
||
content = this.addMetadata(content, note);
|
||
|
||
await note.setContent(content);
|
||
}
|
||
|
||
async formatCodeNote(note) {
|
||
const content = await note.getContent();
|
||
const language = note.getLabelValue('language') || 'javascript';
|
||
|
||
// Add syntax highlighting hints
|
||
if (!note.hasLabel('language')) {
|
||
await note.addLabel('language', language);
|
||
}
|
||
|
||
// Format based on language
|
||
if (language === 'javascript' || language === 'typescript') {
|
||
await this.formatJavaScript(note, content);
|
||
} else if (language === 'python') {
|
||
await this.formatPython(note, content);
|
||
}
|
||
}
|
||
|
||
async formatBookNote(note) {
|
||
// Organize child notes
|
||
const children = await note.getChildNotes();
|
||
|
||
// Sort chapters
|
||
const chapters = children.filter(n => n.hasLabel('chapter'));
|
||
chapters.sort((a, b) => {
|
||
const aNum = parseInt(a.getLabelValue('chapter')) || 999;
|
||
const bNum = parseInt(b.getLabelValue('chapter')) || 999;
|
||
return aNum - bNum;
|
||
});
|
||
|
||
// Generate table of contents
|
||
const toc = this.generateBookTOC(chapters);
|
||
await note.setContent(toc);
|
||
}
|
||
|
||
addTableOfContents(content) {
|
||
const $content = $('<div>').html(content);
|
||
const headings = $content.find('h1, h2, h3');
|
||
|
||
if (headings.length < 3) return content;
|
||
|
||
let toc = '<div class="table-of-contents">\n<h2>Table of Contents</h2>\n<ul>\n';
|
||
|
||
headings.each((i, heading) => {
|
||
const $h = $(heading);
|
||
const level = parseInt(heading.tagName.substring(1));
|
||
const text = $h.text();
|
||
const id = `heading-${i}`;
|
||
|
||
$h.attr('id', id);
|
||
|
||
const indent = ' '.repeat(level - 1);
|
||
toc += `${indent}<li><a href="#${id}">${text}</a></li>\n`;
|
||
});
|
||
|
||
toc += '</ul>\n</div>\n\n';
|
||
|
||
return toc + $content.html();
|
||
}
|
||
|
||
formatHeadings(content) {
|
||
const $content = $('<div>').html(content);
|
||
|
||
// Ensure proper heading hierarchy
|
||
let lastLevel = 0;
|
||
$content.find('h1, h2, h3, h4, h5, h6').each((i, heading) => {
|
||
const $h = $(heading);
|
||
const level = parseInt(heading.tagName.substring(1));
|
||
|
||
// Fix heading jumps (e.g., h1 -> h3 becomes h1 -> h2)
|
||
if (level > lastLevel + 1) {
|
||
const newTag = `h${lastLevel + 1}`;
|
||
const $newHeading = $(`<${newTag}>`).html($h.html());
|
||
$h.replaceWith($newHeading);
|
||
}
|
||
|
||
lastLevel = level;
|
||
});
|
||
|
||
return $content.html();
|
||
}
|
||
|
||
formatLists(content) {
|
||
const $content = $('<div>').html(content);
|
||
|
||
// Add classes to lists for styling
|
||
$content.find('ul').addClass('formatted-list');
|
||
$content.find('ol').addClass('formatted-list numbered');
|
||
|
||
// Add checkboxes to task lists
|
||
$content.find('li').each((i, li) => {
|
||
const $li = $(li);
|
||
const text = $li.text();
|
||
|
||
if (text.startsWith('[ ] ')) {
|
||
$li.html(`<input type="checkbox"> ${text.substring(4)}`);
|
||
$li.addClass('task-item');
|
||
} else if (text.startsWith('[x] ')) {
|
||
$li.html(`<input type="checkbox" checked> ${text.substring(4)}`);
|
||
$li.addClass('task-item completed');
|
||
}
|
||
});
|
||
|
||
return $content.html();
|
||
}
|
||
|
||
addMetadata(content, note) {
|
||
const metadata = {
|
||
lastFormatted: new Date().toISOString(),
|
||
wordCount: content.replace(/<[^>]*>/g, '').split(/\s+/).length,
|
||
noteId: note.noteId
|
||
};
|
||
|
||
const metadataHtml = `
|
||
<div class="note-metadata" style="display:none;">
|
||
${JSON.stringify(metadata)}
|
||
</div>
|
||
`;
|
||
|
||
return content + metadataHtml;
|
||
}
|
||
|
||
async formatJavaScript(note, content) {
|
||
// Add JSDoc comments if missing
|
||
const lines = content.split('\n');
|
||
const formatted = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
|
||
// Detect function declarations
|
||
if (line.match(/^\s*(async\s+)?function\s+\w+/)) {
|
||
if (i === 0 || !lines[i-1].includes('*/')) {
|
||
formatted.push('/**');
|
||
formatted.push(' * [Description]');
|
||
formatted.push(' */');
|
||
}
|
||
}
|
||
|
||
formatted.push(line);
|
||
}
|
||
|
||
await note.setContent(formatted.join('\n'));
|
||
}
|
||
|
||
async formatPython(note, content) {
|
||
// Add docstrings if missing
|
||
const lines = content.split('\n');
|
||
const formatted = [];
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
|
||
// Detect function definitions
|
||
if (line.match(/^\s*def\s+\w+/)) {
|
||
formatted.push(line);
|
||
if (i + 1 < lines.length && !lines[i + 1].includes('"""')) {
|
||
formatted.push(' """[Description]"""');
|
||
}
|
||
} else {
|
||
formatted.push(line);
|
||
}
|
||
}
|
||
|
||
await note.setContent(formatted.join('\n'));
|
||
}
|
||
|
||
generateBookTOC(chapters) {
|
||
let toc = '<h1>Table of Contents</h1>\n<ol>\n';
|
||
|
||
for (const chapter of chapters) {
|
||
const num = chapter.getLabelValue('chapter');
|
||
const title = chapter.title;
|
||
toc += ` <li><a href="#${chapter.noteId}">${num}. ${title}</a></li>\n`;
|
||
}
|
||
|
||
toc += '</ol>';
|
||
return toc;
|
||
}
|
||
}
|
||
|
||
// Initialize formatter
|
||
const formatter = new NoteFormatter();
|
||
|
||
// Add settings UI
|
||
api.addSettingsTab({
|
||
tabId: 'autoFormat',
|
||
title: 'Auto-Format',
|
||
render: () => {
|
||
return `
|
||
<div class="auto-format-settings">
|
||
<h3>Auto-Format Settings</h3>
|
||
|
||
<label>
|
||
<input type="checkbox" id="enableAutoFormat">
|
||
Enable auto-formatting
|
||
</label>
|
||
|
||
<label>
|
||
<input type="checkbox" id="formatOnSave">
|
||
Format on save
|
||
</label>
|
||
|
||
<label>
|
||
<input type="checkbox" id="addTOC">
|
||
Auto-add table of contents
|
||
</label>
|
||
|
||
<h4>Format Rules</h4>
|
||
<textarea id="formatRules" rows="10">
|
||
{
|
||
"headings": true,
|
||
"lists": true,
|
||
"tables": true,
|
||
"codeBlocks": true
|
||
}
|
||
</textarea>
|
||
|
||
<button onclick="saveFormatSettings()">Save Settings</button>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// Save settings function
|
||
window.saveFormatSettings = async () => {
|
||
const settings = {
|
||
enableAutoFormat: document.getElementById('enableAutoFormat').checked,
|
||
formatOnSave: document.getElementById('formatOnSave').checked,
|
||
addTOC: document.getElementById('addTOC').checked,
|
||
rules: JSON.parse(document.getElementById('formatRules').value)
|
||
};
|
||
|
||
await api.setOption('autoFormatSettings', JSON.stringify(settings));
|
||
await api.showMessage('Settings saved');
|
||
};
|
||
|
||
console.log('Auto-formatting script loaded');
|
||
```
|
||
|
||
## Advanced Techniques
|
||
|
||
### Working with External APIs
|
||
|
||
```javascript
|
||
// Fetch data from external API
|
||
async function fetchExternalData() {
|
||
try {
|
||
const response = await fetch('https://api.example.com/data', {
|
||
headers: {
|
||
'Authorization': `Bearer ${await api.getOption('apiKey')}`
|
||
}
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// Store in note
|
||
const dataNote = await api.createDataNote(
|
||
'root',
|
||
'External Data',
|
||
data
|
||
);
|
||
|
||
await api.showMessage('Data imported successfully');
|
||
|
||
} catch (error) {
|
||
await api.showError(`Failed to fetch data: ${error.message}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
### State Management
|
||
|
||
```javascript
|
||
// Create a state manager
|
||
class StateManager {
|
||
constructor() {
|
||
this.state = {};
|
||
this.subscribers = [];
|
||
this.loadState();
|
||
}
|
||
|
||
async loadState() {
|
||
const stored = await api.getOption('scriptState');
|
||
if (stored) {
|
||
this.state = JSON.parse(stored);
|
||
}
|
||
}
|
||
|
||
async setState(key, value) {
|
||
this.state[key] = value;
|
||
await this.saveState();
|
||
this.notifySubscribers(key, value);
|
||
}
|
||
|
||
getState(key) {
|
||
return this.state[key];
|
||
}
|
||
|
||
async saveState() {
|
||
await api.setOption('scriptState', JSON.stringify(this.state));
|
||
}
|
||
|
||
subscribe(callback) {
|
||
this.subscribers.push(callback);
|
||
}
|
||
|
||
notifySubscribers(key, value) {
|
||
this.subscribers.forEach(cb => cb(key, value));
|
||
}
|
||
}
|
||
|
||
const state = new StateManager();
|
||
```
|
||
|
||
### Custom UI Components
|
||
|
||
```javascript
|
||
// Create custom panel
|
||
class CustomPanel {
|
||
constructor() {
|
||
this.createPanel();
|
||
}
|
||
|
||
createPanel() {
|
||
const $panel = $(`
|
||
<div id="custom-panel" class="custom-panel">
|
||
<div class="panel-header">
|
||
<h3>Custom Panel</h3>
|
||
<button class="close-btn">×</button>
|
||
</div>
|
||
<div class="panel-body">
|
||
<!-- Content here -->
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
$('body').append($panel);
|
||
|
||
// Add styles
|
||
$('<style>').text(`
|
||
.custom-panel {
|
||
position: fixed;
|
||
right: 20px;
|
||
top: 80px;
|
||
width: 300px;
|
||
background: var(--main-background-color);
|
||
border: 1px solid var(--main-border-color);
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||
z-index: 1000;
|
||
}
|
||
|
||
.panel-header {
|
||
padding: 10px;
|
||
border-bottom: 1px solid var(--main-border-color);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.panel-body {
|
||
padding: 15px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.close-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
}
|
||
`).appendTo('head');
|
||
|
||
// Bind events
|
||
$panel.find('.close-btn').on('click', () => {
|
||
$panel.hide();
|
||
});
|
||
}
|
||
|
||
show() {
|
||
$('#custom-panel').show();
|
||
}
|
||
|
||
hide() {
|
||
$('#custom-panel').hide();
|
||
}
|
||
|
||
setContent(html) {
|
||
$('#custom-panel .panel-body').html(html);
|
||
}
|
||
}
|
||
|
||
const panel = new CustomPanel();
|
||
```
|
||
|
||
## Performance Optimization
|
||
|
||
### Debouncing
|
||
|
||
```javascript
|
||
// Debounce function calls
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
|
||
// Usage
|
||
const debouncedSearch = debounce(async (query) => {
|
||
const results = await api.searchForNotes(query);
|
||
displayResults(results);
|
||
}, 300);
|
||
|
||
// Input handler
|
||
$('#search-input').on('input', (e) => {
|
||
debouncedSearch(e.target.value);
|
||
});
|
||
```
|
||
|
||
### Caching
|
||
|
||
```javascript
|
||
// Implement caching for expensive operations
|
||
class CacheManager {
|
||
constructor(maxAge = 60000) { // 1 minute default
|
||
this.cache = new Map();
|
||
this.maxAge = maxAge;
|
||
}
|
||
|
||
set(key, value) {
|
||
this.cache.set(key, {
|
||
value,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
get(key) {
|
||
const item = this.cache.get(key);
|
||
if (!item) return null;
|
||
|
||
if (Date.now() - item.timestamp > this.maxAge) {
|
||
this.cache.delete(key);
|
||
return null;
|
||
}
|
||
|
||
return item.value;
|
||
}
|
||
|
||
clear() {
|
||
this.cache.clear();
|
||
}
|
||
}
|
||
|
||
const cache = new CacheManager();
|
||
|
||
// Use cache for API calls
|
||
async function getCachedNote(noteId) {
|
||
let note = cache.get(noteId);
|
||
if (!note) {
|
||
note = await api.getNote(noteId);
|
||
cache.set(noteId, note);
|
||
}
|
||
return note;
|
||
}
|
||
```
|
||
|
||
## Error Handling
|
||
|
||
```javascript
|
||
// Global error handler for scripts
|
||
window.addEventListener('error', async (event) => {
|
||
console.error('Script error:', event.error);
|
||
|
||
// Log to note
|
||
const errorNote = await api.getNote('scriptErrorLog');
|
||
if (errorNote) {
|
||
const content = await errorNote.getContent();
|
||
const errorLog = `
|
||
<div class="error-entry">
|
||
<strong>${new Date().toISOString()}</strong><br>
|
||
${event.error.message}<br>
|
||
<pre>${event.error.stack}</pre>
|
||
</div>
|
||
`;
|
||
await errorNote.setContent(content + errorLog);
|
||
}
|
||
|
||
// Notify user
|
||
await api.showError('Script error occurred. Check error log.');
|
||
});
|
||
|
||
// Wrap async operations
|
||
async function safeExecute(func, fallback = null) {
|
||
try {
|
||
return await func();
|
||
} catch (error) {
|
||
console.error('Operation failed:', error);
|
||
await api.showError(`Operation failed: ${error.message}`);
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// Usage
|
||
const result = await safeExecute(
|
||
async () => await riskyOperation(),
|
||
defaultValue
|
||
);
|
||
```
|
||
|
||
## Testing Frontend Scripts
|
||
|
||
```javascript
|
||
// Simple test framework
|
||
class TestRunner {
|
||
constructor() {
|
||
this.tests = [];
|
||
this.results = [];
|
||
}
|
||
|
||
test(name, testFunc) {
|
||
this.tests.push({ name, testFunc });
|
||
}
|
||
|
||
async run() {
|
||
console.log('Running tests...');
|
||
|
||
for (const test of this.tests) {
|
||
try {
|
||
await test.testFunc();
|
||
this.results.push({
|
||
name: test.name,
|
||
status: 'passed'
|
||
});
|
||
console.log(`✓ ${test.name}`);
|
||
} catch (error) {
|
||
this.results.push({
|
||
name: test.name,
|
||
status: 'failed',
|
||
error: error.message
|
||
});
|
||
console.error(`✗ ${test.name}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
this.displayResults();
|
||
}
|
||
|
||
displayResults() {
|
||
const passed = this.results.filter(r => r.status === 'passed').length;
|
||
const failed = this.results.filter(r => r.status === 'failed').length;
|
||
|
||
console.log(`\nResults: ${passed} passed, ${failed} failed`);
|
||
|
||
if (failed > 0) {
|
||
console.log('\nFailed tests:');
|
||
this.results
|
||
.filter(r => r.status === 'failed')
|
||
.forEach(r => console.log(` - ${r.name}: ${r.error}`));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Write tests
|
||
const runner = new TestRunner();
|
||
|
||
runner.test('Note creation', async () => {
|
||
const note = await api.createNote('root', 'Test Note', 'Content');
|
||
if (!note.noteId) throw new Error('Note ID not set');
|
||
if (note.title !== 'Test Note') throw new Error('Title mismatch');
|
||
});
|
||
|
||
runner.test('Attribute handling', async () => {
|
||
const note = await api.getActiveContextNote();
|
||
await note.addLabel('test', 'value');
|
||
const label = note.getLabelValue('test');
|
||
if (label !== 'value') throw new Error('Label value mismatch');
|
||
});
|
||
|
||
// Run tests
|
||
await runner.run();
|
||
```
|
||
|
||
## Best Practices
|
||
|
||
1. **Code Organization**
|
||
- Use classes for complex functionality
|
||
- Separate concerns into modules
|
||
- Keep functions small and focused
|
||
|
||
2. **Performance**
|
||
- Debounce expensive operations
|
||
- Cache frequently accessed data
|
||
- Use async/await properly
|
||
|
||
3. **Error Handling**
|
||
- Always handle errors gracefully
|
||
- Provide meaningful error messages
|
||
- Log errors for debugging
|
||
|
||
4. **User Experience**
|
||
- Show loading states
|
||
- Provide feedback for actions
|
||
- Ensure scripts don't block UI
|
||
|
||
5. **Security**
|
||
- Validate user input
|
||
- Sanitize HTML content
|
||
- Be cautious with external APIs
|
||
|
||
## Troubleshooting
|
||
|
||
### Script Not Running
|
||
- Check the `#run` label is set correctly
|
||
- Verify syntax errors in console
|
||
- Ensure script note is not archived
|
||
|
||
### API Methods Not Available
|
||
- Check you're using correct API version
|
||
- Verify method names and parameters
|
||
- Consult API documentation
|
||
|
||
### Performance Issues
|
||
- Profile script with browser dev tools
|
||
- Implement caching
|
||
- Optimize DOM operations
|
||
|
||
### Event Handlers Not Firing
|
||
- Verify event names are correct
|
||
- Check element selectors
|
||
- Ensure elements exist when binding
|
||
|
||
## Next Steps
|
||
|
||
- Explore the Backend Script Development guide
|
||
- Review existing scripts in the Trilium community
|
||
- Experiment with the Script API documentation |