Compare commits

..

6 Commits

Author SHA1 Message Date
perf3ct
53f31f9c78 feat(docs): try to also sync docs like this 2025-10-21 15:04:55 -07:00
perf3ct
22c5651eeb feat(docs): try to get images to load at least? 2025-09-03 22:53:07 +00:00
perf3ct
68a10a9813 feat(docs): hopefully fix images 2025-09-03 22:39:38 +00:00
perf3ct
b204964e57 feat(docs): also update the Home.md 2025-09-03 21:33:52 +00:00
perf3ct
b2c869d7ab fix(docs): have this ci use explicit token 2025-09-03 18:29:42 +00:00
perf3ct
307e17f9c8 feat(docs): push to wiki when docs change
asdf

asdf
2025-09-03 17:58:04 +00:00
2432 changed files with 70435 additions and 275708 deletions

View File

@@ -1,6 +1,6 @@
root = true
[*.{js,ts,tsx,css}]
[*.{js,ts,.tsx}]
charset = utf-8
end_of_line = lf
indent_size = 4

1
.env Normal file
View File

@@ -0,0 +1 @@
NODE_OPTIONS=--max_old_space_size=4096

3
.envrc
View File

@@ -1,3 +0,0 @@
if has nix; then
use flake
fi

View File

@@ -21,7 +21,7 @@ runs:
# Certificate setup
- name: Import Apple certificates
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
@@ -30,7 +30,7 @@ runs:
- name: Install Installer certificate
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
@@ -74,7 +74,7 @@ runs:
- name: Update build info
shell: ${{ inputs.shell }}
run: pnpm run chore:update-build-info
run: npm run chore:update-build-info
# Critical debugging configuration
- name: Run electron-forge build with enhanced logging
@@ -86,8 +86,7 @@ runs:
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
TARGET_ARCH: ${{ inputs.arch }}
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
# Add DMG signing step
- name: Sign DMG

View File

@@ -10,9 +10,9 @@ runs:
steps:
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: "pnpm"
- name: Install dependencies
shell: bash
@@ -23,7 +23,7 @@ runs:
shell: bash
run: |
pnpm run chore:update-build-info
pnpm run --filter server package
pnpm nx --project=server package
- name: Prepare artifacts
shell: bash
run: |

View File

@@ -1,103 +0,0 @@
name: "Deploy to Cloudflare Pages"
description: "Deploys to Cloudflare Pages on either a temporary branch with preview comment, or on the production version if on the main branch."
inputs:
project_name:
description: "CloudFlare Pages project name"
comment_body:
description: "The message to display when deployment is ready"
default: "Deployment is ready."
required: false
production_url:
description: "The URL to mention as the production URL."
required: true
deploy_dir:
description: "The directory from which to deploy."
required: true
cloudflare_api_token:
description: "The Cloudflare API token to use for deployment."
required: true
cloudflare_account_id:
description: "The Cloudflare account ID to use for deployment."
required: true
github_token:
description: "The GitHub token to use for posting PR comments."
required: true
runs:
using: composite
steps:
# Install wrangler globally to avoid workspace issues
- name: Install Wrangler
shell: bash
run: npm install -g wrangler
# Deploy using Wrangler (use pre-installed wrangler)
- name: Deploy to Cloudflare Pages
id: deploy
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ inputs.cloudflare_api_token }}
accountId: ${{ inputs.cloudflare_account_id }}
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=${{ github.ref_name }}
wranglerVersion: '' # Use pre-installed version
# Deploy preview for PRs
- name: Deploy Preview to Cloudflare Pages
id: preview-deployment
if: github.event_name == 'pull_request'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ inputs.cloudflare_api_token }}
accountId: ${{ inputs.cloudflare_account_id }}
command: pages deploy ${{ inputs.deploy_dir }} --project-name=${{ inputs.project_name}} --branch=pr-${{ github.event.pull_request.number }}
wranglerVersion: '' # Use pre-installed version
# Post deployment URL as PR comment
- name: Comment PR with Preview URL
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
COMMENT_BODY: ${{ inputs.comment_body }}
PRODUCTION_URL: ${{ inputs.production_url }}
PROJECT_NAME: ${{ inputs.project_name }}
with:
github-token: ${{ inputs.github_token }}
script: |
const prNumber = context.issue.number;
// Construct preview URL based on Cloudflare Pages pattern
const projectName = process.env.PROJECT_NAME;
const previewUrl = `https://pr-${prNumber}.${projectName}.pages.dev`;
// Check if we already commented
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const customMessage = process.env.COMMENT_BODY;
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes(customMessage)
);
const mainUrl = process.env.PRODUCTION_URL;
const commentBody = `${customMessage}!\n\n🔗 Preview URL: ${previewUrl}\n📖 Production URL: ${mainUrl}\n\n✅ All checks passed\n\n_This preview will be updated automatically with new commits._`;
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
}

View File

@@ -44,7 +44,7 @@ runs:
steps:
# Checkout branch to compare to [required]
- name: Checkout base branch
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
ref: ${{ inputs.branch }}
path: br-base

View File

@@ -1,334 +0,0 @@
# Trilium Notes - AI Coding Agent Instructions
## Project Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
## Essential Architecture Patterns
### Three-Layer Cache System (Critical to Understand)
- **Becca** (`apps/server/src/becca/`): Server-side entity cache, primary data source
- **Froca** (`apps/client/src/services/froca.ts`): Client-side mirror synchronized via WebSocket
- **Shaca** (`apps/server/src/share/`): Optimized cache for public/shared notes
**Key insight**: Never bypass these caches with direct DB queries. Always use `becca.notes[noteId]`, `froca.getNote()`, or equivalent cache methods.
### Entity Relationship Model
Notes use a **multi-parent tree** via branches:
- `BNote` - The note content and metadata
- `BBranch` - Tree relationships (one note can have multiple parents via cloning)
- `BAttribute` - Key-value metadata attached to notes (labels and relations)
### Entity Change System & Sync
Every entity modification (notes, branches, attributes) creates an `EntityChange` record that drives synchronization:
```typescript
// Entity changes are automatically tracked
note.title = "New Title";
note.save(); // Creates EntityChange record with changeId
// Sync protocol via WebSocket
ws.sendMessage({ type: 'sync-pull-in-progress', ... });
```
**Critical**: This is why you must use Becca/Froca methods instead of direct DB writes - they create the change tracking records needed for sync.
### Entity Lifecycle & Events
The event system (`apps/server/src/services/events.ts`) broadcasts entity lifecycle events:
```typescript
// Subscribe to events in widgets or services
eventService.subscribe('noteChanged', ({ noteId }) => {
// React to note changes
});
// Common events: noteChanged, branchChanged, attributeChanged, noteDeleted
// Widget method: entitiesReloadedEvent({loadResults}) for handling reloads
```
**Becca loader priorities**: Events are emitted in order (notes → branches → attributes) during initial load to ensure referential integrity.
### TaskContext for Long Operations
Use `TaskContext` for operations with progress reporting (imports, exports, bulk operations):
```typescript
const taskContext = new TaskContext("task-id", "import", "Import Notes");
taskContext.increaseProgressCount();
// WebSocket messages: { type: 'taskProgressCount', taskId, taskType, data, progressCount }
**Pattern**: All long-running operations (delete note trees, export, import) use TaskContext to send WebSocket updates to the frontend.
### Protected Session Handling
Protected notes require an active encryption session:
```typescript
// Always check before accessing protected content
if (note.isContentAvailable()) {
const content = note.getContent(); // Safe
} else {
const title = note.getTitleOrProtected(); // Returns "[protected]"
}
// Protected session management
protectedSessionService.isProtectedSessionAvailable() // Check session
protectedSessionService.startProtectedSession() // After password entry
```
**Session timeout**: Protected sessions expire after inactivity. The encryption key is kept in memory only.
### Attribute Inheritance Patterns
Attributes can be inherited through three mechanisms:
```typescript
// 1. Standard inheritance (#hidePromotedAttributes ~hidePromotedAttributes)
note.getInheritableAttributes() // Walks up parent tree
// 2. Child prefix inheritance (child:label copies to children)
parentNote.setLabel("child:icon", "book") // All children inherit this
// 3. Template relation inheritance (#template=templateNoteId)
note.setRelation("template", templateNoteId)
note.getInheritedAttributes() // Includes template's inheritable attributes
```
**Cycle prevention**: Inheritance tracking prevents infinite loops when notes reference each other.
### Widget-Based UI Architecture
All UI components extend from widget base classes (`apps/client/src/widgets/`):
```typescript
// Right panel widget (sidebar)
class MyWidget extends RightPanelWidget {
get position() { return 100; } // Order in panel
get parentWidget() { return 'right-pane'; }
isEnabled() { return this.note && this.note.hasLabel('myLabel'); }
async refreshWithNote(note) { /* Update UI */ }
}
// Note-aware widget (responds to note changes)
class MyNoteWidget extends NoteContextAwareWidget {
async refreshWithNote(note) { /* Refresh when note changes */ }
async entitiesReloadedEvent({loadResults}) { /* Handle entity updates */ }
}
```
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
## Development Workflow
### Running & Testing
```bash
# From root directory
pnpm install # Install dependencies
corepack enable # Enable pnpm if not available
pnpm server:start # Dev server (http://localhost:8080)
pnpm server:start-prod # Production mode server
pnpm desktop:start # Desktop app development
pnpm server:test spec/etapi/search.spec.ts # Run specific test
pnpm test:parallel # Client tests (can run parallel)
pnpm test:sequential # Server tests (sequential due to shared DB)
pnpm test:all # All tests (parallel + sequential)
pnpm coverage # Generate coverage reports
pnpm typecheck # Type check all projects
```
### Building
```bash
pnpm client:build # Build client application
pnpm server:build # Build server application
pnpm desktop:build # Build desktop application
```
### Test Organization
- **Server tests** (`apps/server/spec/`): Must run sequentially (shared database state)
- **Client tests** (`apps/client/src/`): Can run in parallel
- **E2E tests** (`apps/server-e2e/`): Use Playwright for integration testing
- **ETAPI tests** (`apps/server/spec/etapi/`): External API contract tests
**Pattern**: When adding new API endpoints, add tests in `spec/etapi/` following existing patterns (see `search.spec.ts`).
### Monorepo Navigation
```
apps/
client/ # Frontend (shared by server & desktop)
server/ # Node.js backend with REST API
desktop/ # Electron wrapper
web-clipper/ # Browser extension for saving web content
db-compare/ # Database comparison tool
dump-db/ # Database export utility
edit-docs/ # Documentation editing tools
packages/
commons/ # Shared types and utilities
ckeditor5/ # Custom rich text editor with Trilium-specific plugins
codemirror/ # Code editor integration
highlightjs/ # Syntax highlighting
share-theme/ # Theme for shared/published notes
ckeditor5-admonition/ # Admonition blocks plugin
ckeditor5-footnotes/ # Footnotes plugin
ckeditor5-math/ # Math equations plugin
ckeditor5-mermaid/ # Mermaid diagrams plugin
```
**Filter commands**: Use `pnpm --filter server test` to run commands in specific packages.
## Critical Code Patterns
### ETAPI Backwards Compatibility
When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), maintain backwards compatibility by checking if new params exist before changing response format.
**Pattern**: ETAPI consumers expect specific response shapes. Always check for breaking changes.
### Frontend-Backend Communication
- **REST API**: `apps/server/src/routes/api/` - Internal endpoints (no auth required when `noAuthentication=true`)
- **ETAPI**: `apps/server/src/etapi/` - External API with authentication
- **WebSocket**: Real-time sync via `apps/server/src/services/ws.ts`
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
### Database Migrations
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
- Never bypass Becca cache after migrations
## Common Pitfalls
1. **Never bypass the cache layers** - Always use `becca.notes[noteId]`, `froca.getNote()`, or equivalent cache methods. Direct database queries will cause sync issues between Becca/Froca/Shaca and won't create EntityChange records needed for synchronization.
2. **Protected notes require session check** - Before accessing `note.title` or `note.getContent()` on protected notes, check `note.isContentAvailable()` or use `note.getTitleOrProtected()` which handles this automatically.
3. **Widget lifecycle matters** - Override `refreshWithNote()` for note changes, `doRenderBody()` for initial render, `entitiesReloadedEvent()` for entity updates. Widgets use jQuery (`this.$widget`) - don't mix React patterns.
4. **Tests run differently** - Server tests must run sequentially (shared database state), client tests can run in parallel. Use `pnpm test:sequential` for backend, `pnpm test:parallel` for frontend.
5. **ETAPI requires authentication** - ETAPI endpoints use basic auth with tokens. Internal API endpoints (`apps/server/src/routes/api/`) trust the frontend when `noAuthentication=true`.
6. **Search expressions are evaluated in memory** - The search service loads all matching notes, scores them in JavaScript, then sorts. You cannot add SQL-level LIMIT/OFFSET without losing scoring functionality.
7. **Documentation edits have rules** - `docs/Script API/` is auto-generated (never edit directly). `docs/User Guide/` should be edited via `pnpm edit-docs:edit-docs`, not manually. Only `docs/Developer Guide/` and `docs/Release Notes/` are safe for direct Markdown editing.
8. **pnpm workspace filtering** - Use `pnpm --filter server <command>` or shorthand `pnpm server:test` defined in root `package.json`. Note the `--filter` syntax, not `-F` or other shortcuts.
9. **Event subscription cleanup** - When subscribing to events in widgets, unsubscribe in `cleanup()` or `doDestroy()` to prevent memory leaks.
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
## TypeScript Configuration
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
- **Path mapping**: Use relative imports, not path aliases
- **Build order**: `pnpm typecheck` builds all projects in dependency order
- **Build system**: Uses Vite for fast development, ESBuild for production optimization
- **Patches**: Custom patches in `patches/` directory for CKEditor and other dependencies
## Key Files for Context
- `apps/server/src/becca/entities/bnote.ts` - Note entity methods
- `apps/client/src/services/froca.ts` - Frontend cache API
- `apps/server/src/services/search/services/search.ts` - Search implementation
- `apps/server/src/routes/routes.ts` - API route registration
- `apps/client/src/widgets/basic_widget.ts` - Widget base class
- `apps/server/src/main.ts` - Server startup entry point
- `apps/client/src/desktop.ts` - Client initialization
- `apps/server/src/services/backend_script_api.ts` - Scripting API
- `apps/server/src/assets/db/schema.sql` - Database schema
## Note Types and Features
Trilium supports multiple note types with specialized widgets in `apps/client/src/widgets/type_widgets/`:
- **Text**: Rich text with CKEditor5 (markdown import/export)
- **Code**: Syntax-highlighted code editing with CodeMirror
- **File**: Binary file attachments
- **Image**: Image display with editing capabilities
- **Canvas**: Drawing/diagramming with Excalidraw
- **Mermaid**: Diagram generation
- **Relation Map**: Visual note relationship mapping
- **Web View**: Embedded web pages
- **Doc/Book**: Hierarchical documentation structure
### Collections
Notes can be marked with the `#collection` label to enable collection view modes. Collections support multiple view types:
- **List**: Standard list view
- **Grid**: Card/grid layout
- **Calendar**: Calendar-based view
- **Table**: Tabular data view
- **GeoMap**: Geographic map view
- **Board**: Kanban-style board
- **Presentation**: Slideshow presentation mode
View types are configured via `#viewType` label (e.g., `#viewType=table`). Each view mode stores its configuration in a separate attachment (e.g., `table.json`). Collections are organized separately from regular note type templates in the note creation menu.
## Common Development Tasks
### Adding New Note Types
1. Create widget in `apps/client/src/widgets/type_widgets/`
2. Register in `apps/client/src/services/note_types.ts`
3. Add backend handling in `apps/server/src/services/notes.ts`
### Extending Search
- Search expressions handled in `apps/server/src/services/search/`
- Add new search operators in search context files
- Remember: scoring happens in-memory, not at database level
### Custom CKEditor Plugins
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
- See `ckeditor5-admonition`, `ckeditor5-footnotes`, `ckeditor5-math`, `ckeditor5-mermaid` for examples
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
- Never bypass Becca cache after migrations
## Security & Features
### Security Considerations
- Per-note encryption with granular protected sessions
- CSRF protection for API endpoints
- OpenID and TOTP authentication support
- Sanitization of user-generated content
### Scripting System
Trilium provides powerful user scripting capabilities:
- **Frontend scripts**: Run in browser context with UI access
- **Backend scripts**: Run in Node.js context with full API access
- Script API documentation in `docs/Script API/`
- Backend API available via `api` object in script context
### Internationalization
- Translation files in `apps/client/src/translations/`
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
## Testing Conventions
```typescript
// ETAPI test pattern
describe("etapi/feature", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
app = await buildApp();
token = await login(app);
});
it("should test feature", async () => {
const response = await supertest(app)
.get("/etapi/notes?search=test")
.auth(USER, token, { type: "basic" })
.expect(200);
expect(response.body.results).toBeDefined();
});
});
```
## Questions to Verify Understanding
Before implementing significant changes, confirm:
- Is this touching the cache layer? (Becca/Froca/Shaca must stay in sync via EntityChange records)
- Does this change API response shape? (Check backwards compatibility for ETAPI)
- Are you adding search features? (Understand expression-based architecture and in-memory scoring first)
- Is this a new widget? (Know which base class and lifecycle methods to use)
- Does this involve protected notes? (Check `isContentAvailable()` before accessing content)
- Is this a long-running operation? (Use TaskContext for progress reporting)
- Are you working with attributes? (Understand inheritance patterns: direct, child-prefix, template)

40
.github/instructions/nx.instructions.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.3.9 and pnpm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

View File

@@ -0,0 +1,453 @@
#!/usr/bin/env tsx
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Dirent } from 'fs';
const execAsync = promisify(exec);
// Configuration
const FILE_EXTENSIONS = ['.md', '.png', '.jpg', '.jpeg', '.gif', '.svg'] as const;
const README_PATTERN = /^README(?:[-.](.+))?\.md$/;
interface SyncConfig {
mainRepoPath: string;
wikiPath: string;
docsPath: string;
}
/**
* Convert markdown to GitHub Wiki format
* - Images: ![](image.png) → [[image.png]]
* - Links: [text](page.md) → [[text|page]]
*/
async function convertToWikiFormat(wikiDir: string): Promise<void> {
console.log('Converting to GitHub Wiki format...');
const mdFiles = await findFiles(wikiDir, ['.md']);
let convertedCount = 0;
for (const file of mdFiles) {
let content = await fs.readFile(file, 'utf-8');
const originalContent = content;
// Convert image references to wiki format
// ![alt](image.png) → [[image.png]]
content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
// Skip external URLs
if (src.startsWith('http://') || src.startsWith('https://')) {
return match;
}
// Decode URL encoding
let imagePath = src;
if (src.includes('%')) {
try {
imagePath = decodeURIComponent(src);
} catch {
imagePath = src;
}
}
// Extract just the filename for wiki syntax
const filename = path.basename(imagePath);
// Use wiki syntax for images
// If alt text exists, add it after pipe
if (alt && alt.trim()) {
return `[[${filename}|alt=${alt}]]`;
} else {
return `[[${filename}]]`;
}
});
// Convert internal markdown links to wiki format
// [text](../path/to/Page.md) → [[text|Page]]
content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
// Skip external URLs, anchors, and images
if (href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('#') ||
href.match(/\.(png|jpg|jpeg|gif|svg)$/i)) {
return match;
}
// Check if it's a markdown file link
if (href.endsWith('.md') || href.includes('.md#')) {
// Decode URL encoding
let decodedHref = href;
if (href.includes('%')) {
try {
decodedHref = decodeURIComponent(href);
} catch {
decodedHref = href;
}
}
// Extract page name without extension and path
let pageName = decodedHref
.replace(/\.md(#.*)?$/, '') // Remove .md and anchor
.split('/') // Split by path
.pop() || ''; // Get last part (filename)
// Convert spaces to hyphens (GitHub wiki convention)
pageName = pageName.replace(/ /g, '-');
// Use wiki link syntax
if (text === pageName || text === pageName.replace(/-/g, ' ')) {
return `[[${pageName}]]`;
} else {
return `[[${text}|${pageName}]]`;
}
}
// For other internal links, just decode URL encoding
if (href.includes('%') && !href.startsWith('http')) {
try {
const decodedHref = decodeURIComponent(href);
return `[${text}](${decodedHref})`;
} catch {
return match;
}
}
return match;
});
// Save if modified
if (content !== originalContent) {
await fs.writeFile(file, content, 'utf-8');
const relativePath = path.relative(wikiDir, file);
console.log(` Converted: ${relativePath}`);
convertedCount++;
}
}
if (convertedCount > 0) {
console.log(`Converted ${convertedCount} files to wiki format`);
} else {
console.log('No files needed conversion');
}
}
/**
* Recursively find all files matching the given extensions
*/
async function findFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
const files: string[] = [];
async function walk(currentDir: string): Promise<void> {
const entries: Dirent[] = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
}
}
await walk(dir);
return files;
}
/**
* Get all files in a directory recursively
*/
async function getAllFiles(dir: string): Promise<Set<string>> {
const files = new Set<string>();
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(dir, fullPath);
// Skip .git directory
if (entry.name === '.git' || relativePath.startsWith('.git')) continue;
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.add(relativePath);
}
}
} catch (error) {
// Directory might not exist yet
if ((error as any).code !== 'ENOENT') {
throw error;
}
}
}
await walk(dir);
return files;
}
/**
* Flatten directory structure - move all files to root
* GitHub Wiki prefers flat structure
*/
async function flattenStructure(wikiDir: string): Promise<void> {
console.log('Flattening directory structure for wiki...');
const allFiles = await getAllFiles(wikiDir);
let movedCount = 0;
for (const file of allFiles) {
// Skip if already at root
if (!file.includes('/')) continue;
const oldPath = path.join(wikiDir, file);
const basename = path.basename(file);
// Create unique name if file already exists at root
let newName = basename;
let counter = 1;
while (await fileExists(path.join(wikiDir, newName))) {
const ext = path.extname(basename);
const nameWithoutExt = basename.slice(0, -ext.length);
newName = `${nameWithoutExt}-${counter}${ext}`;
counter++;
}
const newPath = path.join(wikiDir, newName);
// Move file to root
await fs.rename(oldPath, newPath);
console.log(` Moved: ${file}${newName}`);
movedCount++;
}
if (movedCount > 0) {
console.log(`Moved ${movedCount} files to root`);
// Clean up empty directories
await cleanEmptyDirectories(wikiDir);
}
}
async function fileExists(path: string): Promise<boolean> {
try {
await fs.access(path);
return true;
} catch {
return false;
}
}
/**
* Remove empty directories recursively
*/
async function cleanEmptyDirectories(dir: string): Promise<void> {
const allDirs = await getAllDirectories(dir);
for (const subDir of allDirs) {
try {
const entries = await fs.readdir(subDir);
if (entries.length === 0 || (entries.length === 1 && entries[0] === '.git')) {
await fs.rmdir(subDir);
}
} catch {
// Ignore errors
}
}
}
/**
* Get all directories recursively
*/
async function getAllDirectories(dir: string): Promise<string[]> {
const dirs: string[] = [];
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== '.git') {
const fullPath = path.join(currentDir, entry.name);
dirs.push(fullPath);
await walk(fullPath);
}
}
} catch {
// Ignore errors
}
}
await walk(dir);
return dirs.sort((a, b) => b.length - a.length); // Sort longest first for cleanup
}
/**
* Sync files from source to wiki
*/
async function syncFiles(sourceDir: string, wikiDir: string): Promise<void> {
console.log('Syncing files to wiki...');
// Get all valid source files
const sourceFiles = await findFiles(sourceDir, FILE_EXTENSIONS);
const sourceRelativePaths = new Set<string>();
// Copy all source files
console.log(`Found ${sourceFiles.length} files to sync`);
for (const file of sourceFiles) {
const relativePath = path.relative(sourceDir, file);
sourceRelativePaths.add(relativePath);
const targetPath = path.join(wikiDir, relativePath);
const targetDir = path.dirname(targetPath);
// Create directory structure
await fs.mkdir(targetDir, { recursive: true });
// Copy file
await fs.copyFile(file, targetPath);
}
// Remove orphaned files
const wikiFiles = await getAllFiles(wikiDir);
for (const wikiFile of wikiFiles) {
if (!sourceRelativePaths.has(wikiFile) && !wikiFile.startsWith('Home')) {
const fullPath = path.join(wikiDir, wikiFile);
await fs.unlink(fullPath);
}
}
}
/**
* Copy root README.md to wiki as Home.md if it exists
*/
async function copyRootReadme(mainRepoPath: string, wikiPath: string): Promise<void> {
const rootReadmePath = path.join(mainRepoPath, 'README.md');
const wikiHomePath = path.join(wikiPath, 'Home.md');
try {
await fs.access(rootReadmePath);
await fs.copyFile(rootReadmePath, wikiHomePath);
console.log(' Copied root README.md as Home.md');
} catch (error) {
console.log(' No root README.md found to use as Home page');
}
}
/**
* Rename README files to wiki-compatible names
*/
async function renameReadmeFiles(wikiDir: string): Promise<void> {
console.log('Converting README files for wiki compatibility...');
const files = await fs.readdir(wikiDir);
for (const file of files) {
const match = file.match(README_PATTERN);
if (match) {
const oldPath = path.join(wikiDir, file);
let newName: string;
if (match[1]) {
// Language-specific README
newName = `Home-${match[1]}.md`;
} else {
// Main README
newName = 'Home.md';
}
const newPath = path.join(wikiDir, newName);
await fs.rename(oldPath, newPath);
console.log(` Renamed: ${file}${newName}`);
}
}
}
/**
* Check if there are any changes in the wiki
*/
async function hasChanges(wikiDir: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd: wikiDir });
return stdout.trim().length > 0;
} catch (error) {
console.error('Error checking git status:', error);
return false;
}
}
/**
* Get configuration from environment variables
*/
function getConfig(): SyncConfig {
const mainRepoPath = process.env.MAIN_REPO_PATH || 'main-repo';
const wikiPath = process.env.WIKI_PATH || 'wiki';
const docsPath = path.join(mainRepoPath, 'docs');
return { mainRepoPath, wikiPath, docsPath };
}
/**
* Main sync function
*/
async function syncDocsToWiki(): Promise<void> {
const config = getConfig();
const flattenWiki = process.env.FLATTEN_WIKI === 'true';
console.log('Starting documentation sync to wiki...');
console.log(`Source: ${config.docsPath}`);
console.log(`Target: ${config.wikiPath}`);
console.log(`Flatten structure: ${flattenWiki}`);
try {
// Verify paths exist
await fs.access(config.docsPath);
await fs.access(config.wikiPath);
// Sync files
await syncFiles(config.docsPath, config.wikiPath);
// Copy root README.md as Home.md
await copyRootReadme(config.mainRepoPath, config.wikiPath);
// Convert to wiki format
await convertToWikiFormat(config.wikiPath);
// Optionally flatten directory structure
if (flattenWiki) {
await flattenStructure(config.wikiPath);
}
// Rename README files to wiki-compatible names
await renameReadmeFiles(config.wikiPath);
// Check for changes
const changed = await hasChanges(config.wikiPath);
if (changed) {
console.log('\nChanges detected in wiki');
process.stdout.write('::set-output name=changes::true\n');
} else {
console.log('\nNo changes detected in wiki');
process.stdout.write('::set-output name=changes::false\n');
}
console.log('Sync completed successfully!');
} catch (error) {
console.error('Error during sync:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
syncDocsToWiki();
}
export { syncDocsToWiki };

437
.github/scripts/sync-docs-to-wiki.ts vendored Executable file
View File

@@ -0,0 +1,437 @@
#!/usr/bin/env tsx
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Dirent } from 'fs';
const execAsync = promisify(exec);
// Configuration
const FILE_EXTENSIONS = ['.md', '.png', '.jpg', '.jpeg', '.gif', '.svg'] as const;
const README_PATTERN = /^README(?:[-.](.+))?\.md$/;
interface SyncConfig {
mainRepoPath: string;
wikiPath: string;
docsPath: string;
}
/**
* Recursively find all files matching the given extensions
*/
async function findFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
const files: string[] = [];
async function walk(currentDir: string): Promise<void> {
const entries: Dirent[] = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
files.push(fullPath);
}
}
}
}
await walk(dir);
return files;
}
/**
* Get all files in a directory recursively
*/
async function getAllFiles(dir: string): Promise<Set<string>> {
const files = new Set<string>();
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(dir, fullPath);
// Skip .git directory
if (entry.name === '.git' || relativePath.startsWith('.git')) continue;
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.add(relativePath);
}
}
} catch (error) {
// Directory might not exist yet
if ((error as any).code !== 'ENOENT') {
throw error;
}
}
}
await walk(dir);
return files;
}
/**
* Sync files from source to wiki, preserving directory structure and removing orphaned files
*/
async function syncFiles(sourceDir: string, wikiDir: string): Promise<void> {
console.log('Analyzing files to sync...');
// Get all valid source files
const sourceFiles = await findFiles(sourceDir, FILE_EXTENSIONS);
const sourceRelativePaths = new Set<string>();
// Copy all source files and track their paths
console.log(`Found ${sourceFiles.length} files to sync`);
let copiedCount = 0;
let skippedCount = 0;
for (const file of sourceFiles) {
const relativePath = path.relative(sourceDir, file);
sourceRelativePaths.add(relativePath);
const targetPath = path.join(wikiDir, relativePath);
const targetDir = path.dirname(targetPath);
// Create directory structure
await fs.mkdir(targetDir, { recursive: true });
// Check if file needs updating (compare modification times)
let needsCopy = true;
try {
const sourceStat = await fs.stat(file);
const targetStat = await fs.stat(targetPath);
// Only copy if source is newer or sizes differ
needsCopy = sourceStat.mtime > targetStat.mtime || sourceStat.size !== targetStat.size;
} catch {
// Target doesn't exist, needs copy
needsCopy = true;
}
if (needsCopy) {
await fs.copyFile(file, targetPath);
console.log(` Updated: ${relativePath}`);
copiedCount++;
} else {
skippedCount++;
}
}
console.log(`Updated ${copiedCount} files, ${skippedCount} unchanged`);
// Find and remove files that don't exist in source
console.log('Checking for orphaned files in wiki...');
const wikiFiles = await getAllFiles(wikiDir);
let removedCount = 0;
for (const wikiFile of wikiFiles) {
// Check if this file should exist (either as-is or will be renamed)
let shouldExist = sourceRelativePaths.has(wikiFile);
// Special handling for Home files that will be created from READMEs
if (wikiFile.startsWith('Home')) {
const readmeVariant1 = wikiFile.replace(/^Home(-.*)?\.md$/, 'README$1.md');
const readmeVariant2 = wikiFile.replace(/^Home-(.+)\.md$/, 'README.$1.md');
shouldExist = sourceRelativePaths.has(readmeVariant1) || sourceRelativePaths.has(readmeVariant2) || sourceRelativePaths.has('README.md');
}
if (!shouldExist) {
const fullPath = path.join(wikiDir, wikiFile);
await fs.unlink(fullPath);
console.log(` Removed: ${wikiFile}`);
removedCount++;
}
}
if (removedCount > 0) {
console.log(`Removed ${removedCount} orphaned files`);
// Clean up empty directories
await cleanEmptyDirectories(wikiDir);
}
}
/**
* Remove empty directories recursively
*/
async function cleanEmptyDirectories(dir: string): Promise<void> {
async function removeEmptyDirs(currentDir: string): Promise<boolean> {
if (currentDir === dir) return false; // Don't remove root
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
// Skip .git directory
const filteredEntries = entries.filter(e => e.name !== '.git');
if (filteredEntries.length === 0) {
await fs.rmdir(currentDir);
return true;
}
// Check subdirectories
for (const entry of filteredEntries) {
if (entry.isDirectory()) {
const subDir = path.join(currentDir, entry.name);
await removeEmptyDirs(subDir);
}
}
// Check again after cleaning subdirectories
const remainingEntries = await fs.readdir(currentDir);
const filteredRemaining = remainingEntries.filter(e => e !== '.git');
if (filteredRemaining.length === 0 && currentDir !== dir) {
await fs.rmdir(currentDir);
return true;
}
return false;
} catch (error) {
return false;
}
}
// Get all directories and process them
const allDirs = await getAllDirectories(dir);
for (const subDir of allDirs) {
await removeEmptyDirs(subDir);
}
}
/**
* Get all directories recursively
*/
async function getAllDirectories(dir: string): Promise<string[]> {
const dirs: string[] = [];
async function walk(currentDir: string): Promise<void> {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== '.git') {
const fullPath = path.join(currentDir, entry.name);
dirs.push(fullPath);
await walk(fullPath);
}
}
} catch {
// Ignore errors
}
}
await walk(dir);
return dirs.sort((a, b) => b.length - a.length); // Sort longest first for cleanup
}
/**
* Fix references in markdown files for wiki compatibility
*
* Issues fixed:
* 1. URL-encoded image references (spaces as %20) need to match actual filenames
* 2. Internal markdown links need to be converted to wiki syntax
* 3. Images can optionally use wiki syntax [[image.png]] for better compatibility
*/
async function fixImageReferences(wikiDir: string): Promise<void> {
console.log('Fixing references for GitHub Wiki compatibility...');
const mdFiles = await findFiles(wikiDir, ['.md']);
let fixedCount = 0;
for (const file of mdFiles) {
let content = await fs.readFile(file, 'utf-8');
let modified = false;
const originalContent = content;
// Step 1: Fix URL-encoded image references
// Convert ![](Name%20With%20Spaces.png) to ![](Name With Spaces.png)
content = content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
// Skip external URLs
if (src.startsWith('http://') || src.startsWith('https://')) {
return match;
}
// Decode URL encoding if present
if (src.includes('%')) {
try {
const decodedSrc = decodeURIComponent(src);
return `![${alt}](${decodedSrc})`;
} catch {
return match;
}
}
return match;
});
// Step 2: Fix internal links - decode URL encoding but keep standard markdown format
// GitHub Wiki actually supports standard markdown links with relative paths
content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
// Skip external URLs, anchors, and images
if (href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('#') ||
href.match(/\.(png|jpg|jpeg|gif|svg)$/i)) {
return match;
}
// Decode URL encoding for all internal links
if (href.includes('%')) {
try {
const decodedHref = decodeURIComponent(href);
return `[${text}](${decodedHref})`;
} catch {
return match;
}
}
return match;
});
// Check if content was modified
if (content !== originalContent) {
modified = true;
await fs.writeFile(file, content, 'utf-8');
const relativePath = path.relative(wikiDir, file);
console.log(` Fixed references in: ${relativePath}`);
fixedCount++;
}
}
if (fixedCount > 0) {
console.log(`Fixed references in ${fixedCount} files`);
} else {
console.log('No references needed fixing');
}
}
/**
* Rename README files to wiki-compatible names
*/
async function renameReadmeFiles(wikiDir: string): Promise<void> {
console.log('Converting README files for wiki compatibility...');
const files = await fs.readdir(wikiDir);
for (const file of files) {
const match = file.match(README_PATTERN);
if (match) {
const oldPath = path.join(wikiDir, file);
let newName: string;
if (match[1]) {
// Language-specific README (e.g., README-ZH_CN.md or README.es.md)
newName = `Home-${match[1]}.md`;
} else {
// Main README
newName = 'Home.md';
}
const newPath = path.join(wikiDir, newName);
await fs.rename(oldPath, newPath);
console.log(` Renamed: ${file}${newName}`);
}
}
}
/**
* Check if there are any changes in the wiki
*/
async function hasChanges(wikiDir: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd: wikiDir });
return stdout.trim().length > 0;
} catch (error) {
console.error('Error checking git status:', error);
return false;
}
}
/**
* Copy root README.md to wiki as Home.md if it exists
*/
async function copyRootReadme(mainRepoPath: string, wikiPath: string): Promise<void> {
const rootReadmePath = path.join(mainRepoPath, 'README.md');
const wikiHomePath = path.join(wikiPath, 'Home.md');
try {
await fs.access(rootReadmePath);
await fs.copyFile(rootReadmePath, wikiHomePath);
console.log(' Copied root README.md as Home.md');
} catch (error) {
// Root README doesn't exist or can't be accessed
console.log(' No root README.md found to use as Home page');
}
}
/**
* Get configuration from environment variables
*/
function getConfig(): SyncConfig {
const mainRepoPath = process.env.MAIN_REPO_PATH || 'main-repo';
const wikiPath = process.env.WIKI_PATH || 'wiki';
const docsPath = path.join(mainRepoPath, 'docs');
return { mainRepoPath, wikiPath, docsPath };
}
/**
* Main sync function
*/
async function syncDocsToWiki(): Promise<void> {
const config = getConfig();
console.log('Starting documentation sync to wiki...');
console.log(`Source: ${config.docsPath}`);
console.log(`Target: ${config.wikiPath}`);
try {
// Verify paths exist
await fs.access(config.docsPath);
await fs.access(config.wikiPath);
// Sync files (copy new/updated, remove orphaned)
await syncFiles(config.docsPath, config.wikiPath);
// Copy root README.md as Home.md
await copyRootReadme(config.mainRepoPath, config.wikiPath);
// Fix image and link references for wiki compatibility
await fixImageReferences(config.wikiPath);
// Rename README files to wiki-compatible names
await renameReadmeFiles(config.wikiPath);
// Check for changes
const changed = await hasChanges(config.wikiPath);
if (changed) {
console.log('\nChanges detected in wiki');
// GitHub Actions output format
process.stdout.write('::set-output name=changes::true\n');
} else {
console.log('\nNo changes detected in wiki');
process.stdout.write('::set-output name=changes::false\n');
}
console.log('Sync completed successfully!');
} catch (error) {
console.error('Error during sync:', error);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
syncDocsToWiki();
}
export { syncDocsToWiki };

View File

@@ -57,7 +57,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -95,6 +95,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

View File

@@ -1,65 +0,0 @@
name: Deploy Standalone App
on:
# Trigger on push to main branch
push:
branches:
- main
# Only run when docs files change
paths:
- 'apps/client/**'
- 'apps/client-standalone/**'
- 'packages/trilium-core/**'
# Allow manual triggering from Actions tab
workflow_dispatch:
# Run on pull requests for preview deployments
pull_request:
paths:
- 'apps/client/**'
- 'apps/client-standalone/**'
- 'packages/trilium-core/**'
jobs:
build-and-deploy:
name: Build and Deploy App
runs-on: ubuntu-latest
timeout-minutes: 10
# Required permissions for deployment
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
id-token: write # For OIDC authentication (if needed)
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Trigger build of app
run: pnpm --filter=client-standalone build
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
if: github.repository == ${{ vars.REPO_MAIN }}
with:
project_name: "trilium-app"
comment_body: "🖥️ App preview is ready"
production_url: "https://app.triliumnotes.org"
deploy_dir: "apps/client-standalone/dist"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,78 +0,0 @@
name: Deploy Documentation
on:
# Trigger on push to main branch
push:
branches:
- main
- master # Also support master branch
# Only run when docs files change
paths:
- 'docs/**'
- 'apps/edit-docs/**'
- 'apps/build-docs/**'
- 'packages/share-theme/**'
# Allow manual triggering from Actions tab
workflow_dispatch:
# Run on pull requests for preview deployments
pull_request:
branches:
- main
- master
paths:
- 'docs/**'
- 'apps/edit-docs/**'
- 'apps/build-docs/**'
- 'packages/share-theme/**'
jobs:
build-and-deploy:
name: Build and Deploy Documentation
runs-on: ubuntu-latest
timeout-minutes: 10
# Required permissions for deployment
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
id-token: write # For OIDC authentication (if needed)
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Trigger build of documentation
run: pnpm docs:build
- name: Validate Built Site
run: |
test -f site/index.html || (echo "ERROR: site/index.html not found" && exit 1)
test -f site/developer-guide/index.html || (echo "ERROR: site/developer-guide/index.html not found" && exit 1)
echo "✓ User Guide and Developer Guide built successfully"
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
if: github.repository == ${{ vars.REPO_MAIN }}
with:
project_name: "trilium-docs"
comment_body: "📚 Documentation preview is ready"
production_url: "https://docs.triliumnotes.org"
deploy_dir: "site"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,24 +19,45 @@ permissions:
pull-requests: write # for PR comments
jobs:
test_dev:
name: Test development
check-affected:
name: Check affected jobs (NX)
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Check affected
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
test_dev:
name: Test development
runs-on: ubuntu-latest
needs:
- check-affected
steps:
- name: Checkout the repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Run the unit tests
run: pnpm run test:all
@@ -45,15 +66,16 @@ jobs:
runs-on: ubuntu-latest
needs:
- test_dev
- check-affected
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger client build
run: pnpm client:build
run: pnpm nx run client:build
- name: Send client bundle stats to RelativeCI
if: false
uses: relative-ci/agent-action@v3
@@ -61,7 +83,7 @@ jobs:
webpackStatsFile: ./apps/client/dist/webpack-stats.json
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
- name: Trigger server build
run: pnpm run server:build
run: pnpm nx run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
@@ -73,6 +95,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build_docker
- check-affected
strategy:
matrix:
include:
@@ -80,7 +103,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Install dependencies
@@ -89,7 +112,7 @@ jobs:
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger build
run: pnpm server:build
run: pnpm nx run server:build
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV

View File

@@ -32,7 +32,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
@@ -44,9 +44,9 @@ jobs:
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: "pnpm"
- name: Install npm dependencies
@@ -82,16 +82,16 @@ jobs:
require-healthy: true
- name: Run Playwright tests
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm --filter=server-e2e e2e
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm exec nx run server-e2e:e2e
- name: Upload Playwright trace
if: failure()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: Playwright trace (${{ matrix.dockerfile }})
path: test-output/playwright/output
- uses: actions/upload-artifact@v6
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: Playwright report (${{ matrix.dockerfile }})
@@ -116,10 +116,10 @@ jobs:
- dockerfile: Dockerfile
platform: linux/arm64
image: ubuntu-24.04-arm
- dockerfile: Dockerfile.legacy
- dockerfile: Dockerfile
platform: linux/arm/v7
image: ubuntu-24.04-arm
- dockerfile: Dockerfile.legacy
- dockerfile: Dockerfile
platform: linux/arm/v8
image: ubuntu-24.04-arm
runs-on: ${{ matrix.image }}
@@ -141,27 +141,23 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Update nightly version
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
run: pnpm run chore:ci-update-nightly-version
- name: Run the TypeScript build
run: pnpm run server:build
- name: Update build info
run: pnpm run chore:update-build-info
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -213,9 +209,9 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}-${{ matrix.dockerfile }}
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
@@ -227,7 +223,7 @@ jobs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*

View File

@@ -19,6 +19,7 @@ concurrency:
cancel-in-progress: true
env:
GITHUB_UPLOAD_URL: https://uploads.github.com/repos/TriliumNext/Notes/releases/179589950/assets{?name,label}
GITHUB_RELEASE_ID: 179589950
permissions:
@@ -45,32 +46,20 @@ jobs:
image: win-signing
shell: cmd
forge_platform: win32
# Exclude ARM64 Linux from default matrix to use native runner
exclude:
- arch: arm64
os:
name: linux
# Add ARM64 Linux with native ubuntu-24.04-arm runner for better-sqlite3 compatibility
include:
- arch: arm64
os:
name: linux
image: ubuntu-24.04-arm
shell: bash
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Update nightly version
run: pnpm run chore:ci-update-nightly-version
run: npm run chore:ci-update-nightly-version
- name: Run the build
uses: ./.github/actions/build-electron
with:
@@ -90,7 +79,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@@ -102,7 +91,7 @@ jobs:
name: Nightly Build
- name: Publish artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: ${{ github.event_name == 'pull_request' }}
with:
name: TriliumNotes ${{ matrix.os.name }} ${{ matrix.arch }}
@@ -122,7 +111,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Run the build
uses: ./.github/actions/build-server
@@ -131,7 +120,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@@ -4,9 +4,6 @@ on:
push:
branches:
- main
- hotfix
paths-ignore:
- "apps/website/**"
pull_request:
permissions:
@@ -14,74 +11,33 @@ permissions:
contents: read
jobs:
e2e:
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
os: ubuntu-22.04
arch: x64
- name: linux-arm64
os: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.os }}
name: E2E tests on ${{ matrix.name }}
env:
TRILIUM_DOCKER: 1
TRILIUM_PORT: 8082
TRILIUM_DATA_DIR: "${{ github.workspace }}/apps/server/spec/db"
TRILIUM_INTEGRATION_TEST: memory
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
filter: tree:0
fetch-depth: 0
# This enables task distribution via Nx Cloud
# Run this command as early as possible, before dependencies are installed
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
# - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- uses: nrwl/nx-set-shas@v4
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps
- name: Build the server
uses: ./.github/actions/build-server
with:
os: linux
arch: ${{ matrix.arch }}
- name: Unpack and start the server
run: |
version=$(node --eval "console.log(require('./package.json').version)")
file=$(find ./upload -name '*.tar.xz' -print -quit)
name=$(basename "$file" .tar.xz)
mkdir -p ./server-dist
tar -xvf "$file" -C ./server-dist
server_dir="./server-dist/TriliumNotes-Server-$version-linux-${{ matrix.arch }}"
if [ ! -d "$server_dir" ]; then
echo Missing dir.
exit 1
fi
cd "$server_dir"
"./trilium.sh" &
sleep 10
- name: Server end-to-end tests
run: pnpm --filter server-e2e e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v6
with:
name: e2e report ${{ matrix.arch }}
path: apps/server-e2e/test-output
- name: Kill the server
if: always()
run: pkill -f trilium || true
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
# - run: npx nx-cloud record -- echo Hello World
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
# When you enable task distribution, run the e2e-ci task instead of e2e
- run: pnpm exec nx affected -t e2e --exclude desktop-e2e

View File

@@ -30,30 +30,18 @@ jobs:
image: win-signing
shell: cmd
forge_platform: win32
# Exclude ARM64 Linux from default matrix to use native runner
exclude:
- arch: arm64
os:
name: linux
# Add ARM64 Linux with native ubuntu-24.04-arm runner for better-sqlite3 compatibility
include:
- arch: arm64
os:
name: linux
image: ubuntu-24.04-arm
shell: bash
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Run the build
uses: ./.github/actions/build-electron
with:
@@ -73,7 +61,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Upload the artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
path: apps/desktop/upload/*.*
@@ -91,7 +79,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Run the build
uses: ./.github/actions/build-server
@@ -100,7 +88,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Upload the artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: upload/*.*
@@ -114,20 +102,20 @@ jobs:
steps:
- run: mkdir upload
- uses: actions/checkout@v6
- uses: actions/checkout@v5
with:
sparse-checkout: |
docs/Release Notes
- name: Download all artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
merge-multiple: true
pattern: release-*
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.3.2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

67
.github/workflows/sync-docs-to-wiki.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Sync Docs to Wiki
on:
push:
branches:
- main
paths:
- 'docs/**'
workflow_dispatch: # Allow manual triggering
permissions:
contents: read # Read access to repository contents
# Note: Writing to wiki requires a PAT or GITHUB_TOKEN with additional permissions
# The default GITHUB_TOKEN cannot write to wikis, so we need to:
# 1. Create a Personal Access Token (PAT) with 'repo' scope
# 2. Add it as a repository secret named WIKI_TOKEN
jobs:
sync-wiki:
runs-on: ubuntu-latest
steps:
- name: Checkout main repository
uses: actions/checkout@v4
with:
path: main-repo
- name: Checkout wiki repository
uses: actions/checkout@v4
with:
repository: TriliumNext/Trilium.wiki
path: wiki
token: ${{ secrets.WIKI_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install tsx for TypeScript execution
run: npm install -g tsx
- name: Setup Git
run: |
git config --global user.email "action@github.com"
git config --global user.name "GitHub Action"
- name: Sync documentation to wiki
id: sync
run: |
tsx main-repo/.github/scripts/sync-docs-to-wiki.ts
env:
MAIN_REPO_PATH: main-repo
WIKI_PATH: wiki
- name: Commit and push changes
if: contains(steps.sync.outputs.changes, 'true')
run: |
cd wiki
git add .
git commit -m "Sync documentation from main repository
Source commit: ${{ github.sha }}
Triggered by: ${{ github.event.head_commit.message }}"
git push
env:
GITHUB_TOKEN: ${{ secrets.WIKI_TOKEN }}

View File

@@ -1,51 +0,0 @@
name: Deploy website
on:
push:
branches:
- main
paths:
- "apps/website/**"
pull_request:
paths:
- "apps/website/**"
release:
types: [ released ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
name: Build & deploy website
permissions:
contents: read
deployments: write
pull-requests: write # For PR preview comments
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --filter website --frozen-lockfile
- name: Build the website
run: pnpm website:build
- name: Deploy
uses: ./.github/actions/deploy-to-cloudflare-pages
with:
project_name: "trilium-homepage"
comment_body: "📚 Website preview is ready"
production_url: "https://triliumnotes.org"
deploy_dir: "apps/website/dist"
cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
github_token: ${{ secrets.GITHUB_TOKEN }}

14
.gitignore vendored
View File

@@ -1,5 +1,4 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
/.cache
# compiled output
dist
@@ -33,22 +32,19 @@ testem.log
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
vite.config.*.timestamp*
vitest.config.*.timestamp*
test-output
apps/*/data*
apps/*/data
apps/*/out
upload
.rollup.cache
*.tsbuildinfo
/.direnv
/result
.svelte-kit
# docs
site/
apps/*/coverage
scripts/translation/.language*.json
.svelte-kit

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0
22.19.0

View File

@@ -5,10 +5,12 @@
"lokalise.i18n-ally",
"ms-azuretools.vscode-docker",
"ms-playwright.playwright",
"nrwl.angular-console",
"redhat.vscode-yaml",
"tobermory.es6-string-html",
"vitest.explorer",
"yzhang.markdown-all-in-one",
"usernamehw.errorlens"
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -14,7 +14,6 @@ usageMatchRegex:
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
- <Trans\s*i18nKey="({key})"[^>]*>
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
# and works like how the i18next framework identifies the namespace scope from the

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9461/mcp"
}
}
}

10
.vscode/settings.json vendored
View File

@@ -5,8 +5,7 @@
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": [
"apps/server/src/assets/translations",
"apps/client/src/translations",
"apps/website/public/translations"
"apps/client/src/translations"
],
"npm.exclude": [
"**/dist",
@@ -37,10 +36,5 @@
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.rules.customizations": [
{ "rule": "*", "severity": "warn" }
]
"nxConsole.generateAiAgentRules": true
}

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using NX, with multiple applications and shared packages.
## Development Commands
@@ -14,9 +14,12 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
### Running Applications
- `pnpm run server:start` - Start development server (http://localhost:8080)
- `pnpm nx run server:serve` - Alternative server start command
- `pnpm nx run desktop:serve` - Run desktop Electron app
- `pnpm run server:start-prod` - Run server in production mode
### Building
- `pnpm nx build <project>` - Build specific project (server, client, desktop, etc.)
- `pnpm run client:build` - Build client application
- `pnpm run server:build` - Build server application
- `pnpm run electron:build` - Build desktop application
@@ -25,8 +28,13 @@ Trilium Notes is a hierarchical note-taking application with advanced features l
- `pnpm test:all` - Run all tests (parallel + sequential)
- `pnpm test:parallel` - Run tests that can run in parallel
- `pnpm test:sequential` - Run tests that must run sequentially (server, ckeditor5-mermaid, ckeditor5-math)
- `pnpm nx test <project>` - Run tests for specific project
- `pnpm coverage` - Generate coverage reports
### Linting & Type Checking
- `pnpm nx run <project>:lint` - Lint specific project
- `pnpm nx run <project>:typecheck` - Type check specific project
## Architecture Overview
### Monorepo Structure
@@ -86,6 +94,7 @@ Frontend uses a widget system (`apps/client/src/widgets/`):
- `apps/server/src/assets/db/schema.sql` - Core database structure
4. **Configuration**:
- `nx.json` - NX workspace configuration
- `package.json` - Project dependencies and scripts
## Note Types and Features
@@ -145,7 +154,7 @@ Trilium provides powerful user scripting capabilities:
- Update schema in `apps/server/src/assets/db/schema.sql`
## Build System Notes
- Uses pnpm for monorepo management
- Uses NX for monorepo management with build caching
- Vite for fast development builds
- ESBuild for production optimization
- pnpm workspaces for dependency management

130
README.md
View File

@@ -1,14 +1,3 @@
<div align="center">
<sup>Special thanks to:</sup><br />
<a href="https://go.warp.dev/Trilium" target="_blank">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png"><br />
Warp, built for coding with multiple AI agents<br />
</a>
<sup>Available for macOS, Linux and Windows</sup>
</div>
<hr />
# Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
@@ -16,63 +5,41 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
<!-- translate:off -->
<!-- LANGUAGE SWITCHER -->
[Chinese (Simplified Han script)](./docs/README-ZH_CN.md) | [Chinese (Traditional Han script)](./docs/README-ZH_TW.md) | [English](./docs/README.md) | [French](./docs/README-fr.md) | [German](./docs/README-de.md) | [Greek](./docs/README-el.md) | [Italian](./docs/README-it.md) | [Japanese](./docs/README-ja.md) | [Romanian](./docs/README-ro.md) | [Spanish](./docs/README-es.md)
<!-- translate:on -->
[English](./README.md) | [Chinese (Simplified)](./docs/README-ZH_CN.md) | [Chinese (Traditional)](./docs/README-ZH_TW.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
<img src="./docs/app.png" alt="Trilium Screenshot" width="1000">
See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for quick overview:
## ⏬ Download
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) stable version, recommended for most users.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) unstable development version, updated daily with the latest features and fixes.
## 📚 Documentation
**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
- [Docker Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
- [Upgrading TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
- [Basic Concepts and Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
- [Patterns of Personal Knowledge Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## 🎁 Features
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown [autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
* Support for editing [notes with source code](https://docs.triliumnotes.org/user-guide/note-types/code), including syntax highlighting
* Fast and easy [navigation between notes](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation), full text search and [note hoisting](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
* Seamless [note versioning](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
* Note [attributes](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) can be used for note organization, querying and advanced [scripting](https://docs.triliumnotes.org/user-guide/scripts)
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown [autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
* Support for editing [notes with source code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax highlighting
* Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
* Direct [OpenID and TOTP integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for more secure login
* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization) with self-hosted sync server
* there are [3rd party services for hosting synchronisation server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (publishing) notes to public internet
* Strong [note encryption](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes) with per-note granularity
* Direct [OpenID and TOTP integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) for more secure login
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
* Strong [note encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with per-note granularity
* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
* [Relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map) for visualizing notes and their relations
* [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and [link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing notes and their relations
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
* [Geo maps](https://docs.triliumnotes.org/user-guide/collections/geomap) with location pins and GPX tracks
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for automation
* [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for smartphones and tablets
* Built-in [dark theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for user themes
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) and [Markdown import & export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for easy saving of web content
* Touch optimized [mobile frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for smartphones and tablets
* Built-in [dark theme](https://triliumnext.github.io/Docs/Wiki/themes), support for user themes
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), along with a Grafana Dashboard.
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
@@ -89,6 +56,19 @@ There are no special migration steps to migrate from a zadam/Trilium instance to
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
## 📖 Documentation
We're currently in the progress of moving the documentation to in-app (hit the `F1` key within Trilium). As a result, there may be some missing parts until we've completed the migration. If you'd prefer to navigate through the documentation within GitHub, you can navigate the [User Guide](./docs/User%20Guide/User%20Guide/) documentation.
Below are some quick links for your convenience to navigate the documentation:
- [Server installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Concepts and Features - Note](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
Until we finish reorganizing the documentation, you may also want to [browse the old documentation](https://triliumnext.github.io/Docs).
## 💬 Discuss with us
Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
@@ -132,7 +112,7 @@ Note: It is best to disable automatic updates on your server installation (see b
### Server
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
## 💻 Contribute
@@ -162,7 +142,7 @@ Download the repository, install dependencies using `pnpm` and then run the envi
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm edit-docs:edit-docs
pnpm nx run edit-docs:edit-docs
```
### Building the Executable
@@ -171,7 +151,7 @@ Download the repository, install dependencies using `pnpm` and then build the de
git clone https://github.com/TriliumNext/Trilium.git
cd Trilium
pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
@@ -182,34 +162,16 @@ Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blo
## 👏 Shoutouts
* [zadam](https://github.com/zadam) for the original concept and implementation of the application.
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the application icon.
* [nriver](https://github.com/nriver) for his work on internationalization.
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
Trilium would not be possible without the technologies behind it:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [link maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team
* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
## 🤝 Support
Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting.
Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via:
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
Support for the TriliumNext organization will be possible in the near future. For now, you can:
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/trilium/graphs/contributors))) for a full list)
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
## 🔑 License

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env node
import anonymizationService from "../src/services/anonymization.js";
import fs from "fs";
import path from "path";
fs.writeFileSync(path.resolve(__dirname, "tpl", "anonymize-database.sql"), anonymizationService.getFullAnonymizationScript());

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
SCHEMA_FILE_PATH=db/schema.sql
sqlite3 ./data/document.db .schema | grep -v "sqlite_sequence" > "$SCHEMA_FILE_PATH"
echo "DB schema exported to $SCHEMA_FILE_PATH"

View File

@@ -6,11 +6,10 @@
import sqlInit from "../src/services/sql_init.js";
import noteService from "../src/services/notes.js";
import attributeService from "../src/services/attributes.js";
import cloningService from "../src/services/cloning.js";
import { loremIpsum } from "lorem-ipsum";
import "../src/becca/entity_constructor.js";
import { initializeTranslations } from "../src/services/i18n.js";
import cls from "../src/services/cls.js";
import cloningService from "../src/services/cloning.js";
import loremIpsum from "lorem-ipsum";
import "../src/becca/entity_constructor.js";
const noteCount = parseInt(process.argv[2]);
@@ -28,18 +27,8 @@ function getRandomNoteId() {
}
async function start() {
if (!sqlInit.isDbInitialized()) {
console.error("Database not initialized.");
process.exit(1);
}
await initializeTranslations();
sqlInit.initializeDb();
await sqlInit.dbReady;
for (let i = 0; i < noteCount; i++) {
const title = loremIpsum({
const title = loremIpsum.loremIpsum({
count: 1,
units: "sentences",
sentenceLowerBound: 1,
@@ -47,7 +36,7 @@ async function start() {
});
const paragraphCount = Math.floor(Math.random() * Math.random() * 100);
const content = loremIpsum({
const content = loremIpsum.loremIpsum({
count: paragraphCount,
units: "paragraphs",
sentenceLowerBound: 1,
@@ -71,13 +60,13 @@ async function start() {
const parentNoteId = getRandomNoteId();
const prefix = Math.random() > 0.8 ? "prefix" : "";
const result = cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
const result = await cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
console.log(`Cloning ${i}:`, result.success ? "succeeded" : "FAILED");
}
// does not have to be for the current note
attributeService.createAttribute({
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "label",
name: "label",
@@ -85,7 +74,7 @@ async function start() {
isInheritable: Math.random() > 0.1 // 10% are inheritable
});
attributeService.createAttribute({
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "relation",
name: "relation",
@@ -101,4 +90,6 @@ async function start() {
process.exit(0);
}
cls.init(() => start());
// @TriliumNextTODO sqlInit.dbReady never seems to resolve so program hangs
// see https://github.com/TriliumNext/Trilium/issues/1020
sqlInit.dbReady.then(cls.wrap(start)).catch((err) => console.error(err));

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
VERSION=$1
SERIES=${VERSION:0:4}-latest
docker push zadam/trilium:$VERSION
docker push zadam/trilium:$SERIES
if [[ $1 != *"beta"* ]]; then
docker push zadam/trilium:latest
fi

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
VERSION=$1
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
then
echo "Version ${VERSION} isn't in format X.Y.Z"
exit 1
fi
VERSION_DATE=$(git log -1 --format=%aI "v${VERSION}" | cut -c -10)
VERSION_COMMIT=$(git rev-list -n 1 "v${VERSION}")
# expecting the directory at a specific path
cd ~/trilium-flathub || exit
if ! git diff-index --quiet HEAD --; then
echo "There are uncommitted changes"
exit 1
fi
BASE_BRANCH=main
if [[ "$VERSION" == *"beta"* ]]; then
BASE_BRANCH=beta
fi
git switch "${BASE_BRANCH}"
git pull
BRANCH=b${VERSION}
git branch "${BRANCH}"
git switch "${BRANCH}"
echo "Updating files with version ${VERSION}, date ${VERSION_DATE} and commit ${VERSION_COMMIT}"
flatpak-node-generator npm ../trilium/package-lock.json
xmlstarlet ed --inplace --update "/component/releases/release/@version" --value "${VERSION}" --update "/component/releases/release/@date" --value "${VERSION_DATE}" ./com.github.zadam.trilium.metainfo.xml
yq --inplace "(.modules[0].sources[0].tag = \"v${VERSION}\") | (.modules[0].sources[0].commit = \"${VERSION_COMMIT}\")" ./com.github.zadam.trilium.yml
git add ./generated-sources.json
git add ./com.github.zadam.trilium.metainfo.xml
git add ./com.github.zadam.trilium.yml
git commit -m "release $VERSION"
git push --set-upstream origin "${BRANCH}"
gh pr create --fill -B "${BASE_BRANCH}"
gh pr merge --auto --merge --delete-branch

49
_regroup/bin/release.sh Normal file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -e
if [[ $# -eq 0 ]] ; then
echo "Missing argument of new version"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Missing command: jq"
exit 1
fi
VERSION=$1
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
then
echo "Version ${VERSION} isn't in format X.Y.Z"
exit 1
fi
if ! git diff-index --quiet HEAD --; then
echo "There are uncommitted changes"
exit 1
fi
echo "Releasing Trilium $VERSION"
jq '.version = "'$VERSION'"' package.json > package.json.tmp
mv package.json.tmp package.json
git add package.json
npm run chore:update-build-info
git add src/services/build.ts
TAG=v$VERSION
echo "Committing package.json version change"
git commit -m "chore(release): $VERSION"
git push
echo "Tagging commit with $TAG"
git tag $TAG
git push origin $TAG

View File

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 383 B

View File

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 357 B

After

Width:  |  Height:  |  Size: 357 B

View File

Before

Width:  |  Height:  |  Size: 387 B

After

Width:  |  Height:  |  Size: 387 B

View File

Before

Width:  |  Height:  |  Size: 734 B

After

Width:  |  Height:  |  Size: 734 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

51
_regroup/eslint.config.js Normal file
View File

@@ -0,0 +1,51 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import simpleImportSort from "eslint-plugin-simple-import-sort";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
// consider using rules below, once we have a full TS codebase and can be more strict
// tseslint.configs.strictTypeChecked,
// tseslint.configs.stylisticTypeChecked,
// tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
}
},
{
plugins: {
"simple-import-sort": simpleImportSort
}
},
{
rules: {
// add rule overrides here
"no-undef": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_"
}
],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error"
}
},
{
ignores: [
"build/*",
"dist/*",
"docs/*",
"demo/*",
"src/public/app-dist/*",
"src/public/app/doc_notes/*"
]
}
);

View File

@@ -0,0 +1,47 @@
import stylistic from "@stylistic/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
// eslint config just for formatting rules
// potentially to be merged with the linting rules into one single config,
// once we have fixed the majority of lint errors
// Go to https://eslint.style/rules/default/${rule_without_prefix} to check the rule details
export const stylisticRules = {
"@stylistic/indent": [ "error", 4 ],
"@stylistic/quotes": [ "error", "double", { avoidEscape: true, allowTemplateLiterals: "always" } ],
"@stylistic/semi": [ "error", "always" ],
"@stylistic/quote-props": [ "error", "consistent-as-needed" ],
"@stylistic/max-len": [ "error", { code: 100 } ],
"@stylistic/comma-dangle": [ "error", "never" ],
"@stylistic/linebreak-style": [ "error", "unix" ],
"@stylistic/array-bracket-spacing": [ "error", "always" ],
"@stylistic/object-curly-spacing": [ "error", "always" ],
"@stylistic/padded-blocks": [ "error", { classes: "always" } ]
};
export default [
{
files: [ "**/*.{js,ts,mjs,cjs}" ],
languageOptions: {
parser: tsParser
},
plugins: {
"@stylistic": stylistic
},
rules: {
...stylisticRules
}
},
{
ignores: [
"build/*",
"dist/*",
"docs/*",
"demo/*",
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
"packages/*",
"src/public/app-dist/*",
"src/public/app/doc_notes/*"
]
}
];

View File

@@ -0,0 +1,17 @@
import { test as setup, expect } from "@playwright/test";
const authFile = "playwright/.auth/user.json";
const ROOT_URL = "http://localhost:8082";
const LOGIN_PASSWORD = "demo1234";
// Reference: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests
setup("authenticate", async ({ page }) => {
await page.goto(ROOT_URL);
await expect(page).toHaveURL(`${ROOT_URL}/login`);
await page.getByRole("textbox", { name: "Password" }).fill(LOGIN_PASSWORD);
await page.getByRole("button", { name: "Login" }).click();
await page.context().storageState({ path: authFile });
});

View File

@@ -0,0 +1,9 @@
import { test, expect } from "@playwright/test";
test("Can duplicate note with broken links", async ({ page }) => {
await page.goto(`http://localhost:8082/#2VammGGdG6Ie`);
await page.locator(".tree-wrapper .fancytree-active").getByText("Note map").click({ button: "right" });
await page.getByText("Duplicate subtree").click();
await expect(page.locator(".toast-body")).toBeHidden();
await expect(page.locator(".tree-wrapper").getByText("Note map (dup)")).toBeVisible();
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test("get started link", async ({ page }) => {
await page.goto("https://playwright.dev/");
// Click the get started link.
await page.getByRole("link", { name: "Get started" }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible();
});

View File

@@ -0,0 +1,21 @@
import test, { expect } from "@playwright/test";
test("Native Title Bar not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsAppearance");
await expect(page.getByRole("heading", { name: "Theme" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Native Title Bar (requires" })).toBeHidden();
});
test("Tray settings not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsOther");
await expect(page.getByRole("heading", { name: "Note Erasure Timeout" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
});
test("Spellcheck settings not displayed on web", async ({ page }) => {
await page.goto("http://localhost:8082/#root/_hidden/_options/_optionsSpellcheck");
await expect(page.getByRole("heading", { name: "Spell Check" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tray" })).toBeHidden();
await expect(page.getByText("These options apply only for desktop builds")).toBeVisible();
await expect(page.getByText("Enable spellcheck")).toBeHidden();
});

View File

@@ -0,0 +1,18 @@
import test, { expect } from "@playwright/test";
test("Renders on desktop", async ({ page, context }) => {
await page.goto("http://localhost:8082");
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
});
test("Renders on mobile", async ({ page, context }) => {
await context.addCookies([
{
url: "http://localhost:8082",
name: "trilium-device",
value: "mobile"
}
]);
await page.goto("http://localhost:8082");
await expect(page.locator(".tree")).toContainText("Trilium Integration Test");
});

View File

@@ -0,0 +1,12 @@
import { test, expect } from "@playwright/test";
const expectedVersion = "0.90.3";
test("Displays update badge when there is a version available", async ({ page }) => {
await page.goto("http://localhost:8080");
await page.getByRole("button", { name: "" }).click();
await page.getByText(`Version ${expectedVersion} is available,`).click();
const page1 = await page.waitForEvent("popup");
expect(page1.url()).toBe(`https://github.com/TriliumNext/Trilium/releases/tag/v${expectedVersion}`);
});

58
_regroup/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"main": "./electron-main.js",
"bin": {
"trilium": "src/main.js"
},
"type": "module",
"scripts": {
"server:start-safe": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nodemon src/main.ts",
"server:start-no-dir": "cross-env TRILIUM_ENV=dev nodemon src/main.ts",
"server:start-test": "npm run server:switch && rimraf ./data-test && cross-env TRILIUM_DATA_DIR=./data-test TRILIUM_ENV=dev TRILIUM_PORT=9999 nodemon src/main.ts",
"server:qstart": "npm run server:switch && npm run server:start",
"server:switch": "rimraf ./node_modules/better-sqlite3 && npm install",
"electron:start-no-dir": "cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 electron --inspect=5858 .",
"electron:start-nix": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
"electron:start-nix-no-dir": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
"electron:start-prod-no-dir": "npm run build:prepare-dist && cross-env TRILIUM_ENV=prod electron --inspect=5858 .",
"electron:start-prod-nix": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
"electron:start-prod-nix-no-dir": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
"electron:qstart": "npm run electron:switch && npm run electron:start",
"electron:switch": "electron-rebuild",
"docs:build": "typedoc",
"test": "npm run client:test && npm run server:test",
"client:test": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app",
"client:coverage": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app --coverage",
"test:playwright": "playwright test --workers 1",
"test:integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
"test:integration-mem-db": "cross-env nodemon src/main.ts",
"test:integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
"dev:watch-dist": "tsx ./bin/watch-dist.ts",
"dev:format-check": "eslint -c eslint.format.config.js .",
"dev:format-fix": "eslint -c eslint.format.config.js . --fix",
"dev:linter-check": "eslint .",
"dev:linter-fix": "eslint . --fix",
"chore:generate-document": "cross-env nodemon ./bin/generate_document.ts 1000",
"chore:generate-openapi": "tsx bin/generate-openapi.js"
},
"devDependencies": {
"@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.3.1",
"@types/express": "5.0.3",
"@types/node": "22.18.0",
"@types/yargs": "17.0.33",
"@vitest/coverage-v8": "3.2.4",
"eslint": "9.34.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25",
"jsdoc": "4.0.4",
"lorem-ipsum": "2.0.8",
"rcedit": "4.0.1",
"rimraf": "6.0.1",
"tslib": "2.8.1",
"typedoc": "0.28.12",
"typedoc-plugin-missing-exports": "4.1.0"
},
"optionalDependencies": {
"appdmg": "0.6.6"
}
}

View File

@@ -0,0 +1,9 @@
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("app_info", () => {
it("get", async () => {
const appInfo = await etapi.getEtapi("app-info");
expect(appInfo.clipperProtocolVersion).toEqual("1.0");
});
});
*/

View File

@@ -0,0 +1,10 @@
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("backup", () => {
it("create", async () => {
const response = await etapi.putEtapiContent("backup/etapi_test");
expect(response.status).toEqual(204);
});
});
*/

View File

@@ -0,0 +1,26 @@
import etapi from "../support/etapi.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("import", () => {
// temporarily skip this test since test-export.zip is missing
xit("import", async () => {
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const zipFileBuffer = fs.readFileSync(path.resolve(scriptDir, "test-export.zip"));
const response = await etapi.postEtapiContent("notes/root/import", zipFileBuffer);
expect(response.status).toEqual(201);
const { note, branch } = await response.json();
expect(note.title).toEqual("test-export");
expect(branch.parentNoteId).toEqual("root");
const content = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(content).toContain("test export content");
});
});
*/

View File

@@ -0,0 +1,103 @@
import crypto from "crypto";
import etapi from "../support/etapi.js";
/* TriliumNextTODO: port to Vitest
etapi.describeEtapi("notes", () => {
it("create", async () => {
const { note, branch } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content",
prefix: "Custom prefix"
});
expect(note.title).toEqual("Hello World!");
expect(branch.parentNoteId).toEqual("root");
expect(branch.prefix).toEqual("Custom prefix");
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
expect(rNote.title).toEqual("Hello World!");
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(rContent).toEqual("Content");
const rBranch = await etapi.getEtapi(`branches/${branch.branchId}`);
expect(rBranch.parentNoteId).toEqual("root");
expect(rBranch.prefix).toEqual("Custom prefix");
});
it("patch", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.patchEtapi(`notes/${note.noteId}`, {
title: "new title",
type: "code",
mime: "text/apl",
dateCreated: "2000-01-01 12:34:56.999+0200",
utcDateCreated: "2000-01-01 10:34:56.999Z"
});
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
expect(rNote.title).toEqual("new title");
expect(rNote.type).toEqual("code");
expect(rNote.mime).toEqual("text/apl");
expect(rNote.dateCreated).toEqual("2000-01-01 12:34:56.999+0200");
expect(rNote.utcDateCreated).toEqual("2000-01-01 10:34:56.999Z");
});
it("update content", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.putEtapiContent(`notes/${note.noteId}/content`, "new content");
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
expect(rContent).toEqual("new content");
});
it("create / update binary content", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "file",
title: "Hello World!",
content: "ZZZ"
});
const updatedContent = crypto.randomBytes(16);
await etapi.putEtapiContent(`notes/${note.noteId}/content`, updatedContent);
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).arrayBuffer();
expect(Buffer.from(new Uint8Array(rContent))).toEqual(updatedContent);
});
it("delete note", async () => {
const { note } = await etapi.postEtapi("create-note", {
parentNoteId: "root",
type: "text",
title: "Hello World!",
content: "Content"
});
await etapi.deleteEtapi(`notes/${note.noteId}`);
const resp = await etapi.getEtapiResponse(`notes/${note.noteId}`);
expect(resp.status).toEqual(404);
const error = await resp.json();
expect(error.status).toEqual(404);
expect(error.code).toEqual("NOTE_NOT_FOUND");
expect(error.message).toEqual(`Note '${note.noteId}' not found.`);
});
});
*/

View File

@@ -0,0 +1,155 @@
import type child_process from "child_process";
import { describe, beforeAll, afterAll } from "vitest";
let etapiAuthToken: string | undefined;
const getEtapiAuthorizationHeader = (): string => "Basic " + Buffer.from(`etapi:${etapiAuthToken}`).toString("base64");
const PORT: string = "9999";
const HOST: string = "http://localhost:" + PORT;
type SpecDefinitionsFunc = () => void;
function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void {
describe(description, () => {
let appProcess: ReturnType<typeof child_process.spawn>;
beforeAll(async () => {});
afterAll(() => {});
specDefinitions();
});
}
async function getEtapiResponse(url: string): Promise<Response> {
return await fetch(`${HOST}/etapi/${url}`, {
method: "GET",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
}
async function getEtapi(url: string): Promise<any> {
const response = await getEtapiResponse(url);
return await processEtapiResponse(response);
}
async function getEtapiContent(url: string): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "GET",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
checkStatus(response);
return response;
}
async function postEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function postEtapiContent(url: string, data: BodyInit): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Authorization: getEtapiAuthorizationHeader()
},
body: data
});
checkStatus(response);
return response;
}
async function putEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function putEtapiContent(url: string, data?: BodyInit): Promise<Response> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PUT",
headers: {
"Content-Type": "application/octet-stream",
Authorization: getEtapiAuthorizationHeader()
},
body: data
});
checkStatus(response);
return response;
}
async function patchEtapi(url: string, data: Record<string, unknown> = {}): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: getEtapiAuthorizationHeader()
},
body: JSON.stringify(data)
});
return await processEtapiResponse(response);
}
async function deleteEtapi(url: string): Promise<any> {
const response = await fetch(`${HOST}/etapi/${url}`, {
method: "DELETE",
headers: {
Authorization: getEtapiAuthorizationHeader()
}
});
return await processEtapiResponse(response);
}
async function processEtapiResponse(response: Response): Promise<any> {
const text = await response.text();
if (response.status < 200 || response.status >= 300) {
throw new Error(`ETAPI error ${response.status}: ${text}`);
}
return text?.trim() ? JSON.parse(text) : null;
}
function checkStatus(response: Response): void {
if (response.status < 200 || response.status >= 300) {
throw new Error(`ETAPI error ${response.status}`);
}
}
export default {
describeEtapi,
getEtapi,
getEtapiResponse,
getEtapiContent,
postEtapi,
postEtapiContent,
putEtapi,
putEtapiContent,
patchEtapi,
deleteEtapi
};

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "NodeNext",
"declaration": false,
"sourceMap": true,
"outDir": "./build",
"strict": true,
"noImplicitAny": true,
"resolveJsonModule": true,
"lib": ["ES2023"],
"downlevelIteration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowJs": true
},
"include": ["./src/public/app/**/*"],
"files": [
"./src/public/app/types.d.ts",
"./src/public/app/types-lib.d.ts",
"./src/types.d.ts"
]
}

15
_regroup/typedoc.json Normal file
View File

@@ -0,0 +1,15 @@
{
"entryPoints": [
"src/services/backend_script_entrypoint.ts",
"src/public/app/services/frontend_script_entrypoint.ts"
],
"plugin": [
"typedoc-plugin-missing-exports"
],
"outputs": [
{
"name": "html",
"path": "./docs/Script API"
}
]
}

View File

@@ -1,22 +0,0 @@
{
"name": "build-docs",
"version": "1.0.0",
"description": "",
"main": "src/main.ts",
"scripts": {
"start": "tsx ."
},
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.27.0",
"devDependencies": {
"@redocly/cli": "2.14.3",
"archiver": "7.0.1",
"fs-extra": "11.3.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"typedoc": "0.28.15",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -1,36 +0,0 @@
/**
* The backend script API is accessible to code notes with the "JS (backend)" language.
*
* The entire API is exposed as a single global: {@link api}
*
* @module Backend Script API
*/
/**
* This file creates the entrypoint for TypeDoc that simulates the context from within a
* script note on the server side.
*
* Make sure to keep in line with backend's `script_context.ts`.
*/
export type { default as AbstractBeccaEntity } from "../../server/src/becca/entities/abstract_becca_entity.js";
export type { default as BAttachment } from "../../server/src/becca/entities/battachment.js";
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
export type { BNote };
export type { default as BOption } from "../../server/src/becca/entities/boption.js";
export type { default as BRecentNote } from "../../server/src/becca/entities/brecent_note.js";
export type { default as BRevision } from "../../server/src/becca/entities/brevision.js";
import BNote from "../../server/src/becca/entities/bnote.js";
import BackendScriptApi, { type Api } from "../../server/src/services/backend_script_api.js";
export type { Api };
const fakeNote = new BNote();
/**
* The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
*/
export const api: Api = new BackendScriptApi(fakeNote, {});

View File

@@ -1,147 +0,0 @@
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
process.env.NODE_ENV = "development";
import cls from "@triliumnext/server/src/services/cls.js";
import { dirname, join, resolve } from "path";
import * as fs from "fs/promises";
import * as fsExtra from "fs-extra";
import archiver from "archiver";
import { WriteStream } from "fs";
import { execSync } from "child_process";
import BuildContext from "./context.js";
const DOCS_ROOT = "../../../docs";
const OUTPUT_DIR = "../../site";
async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
const note = await importData(sourcePath);
// Use a meaningful name for the temporary zip file
const zipName = outputSubDir || "user-guide";
const zipFilePath = `output-${zipName}.zip`;
try {
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
const branch = note.getParentBranches()[0];
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js")).default(
"no-progress-reporting",
"export",
null
);
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
await exportToZip(taskContext, branch, "share", fileOutputStream);
await waitForStreamToFinish(fileOutputStream);
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
const outputPath = outputSubDir ? join(OUTPUT_DIR, outputSubDir) : OUTPUT_DIR;
await extractZip(zipFilePath, outputPath);
} finally {
if (await fsExtra.exists(zipFilePath)) {
await fsExtra.rm(zipFilePath);
}
}
}
async function buildDocsInner() {
const i18n = await import("@triliumnext/server/src/services/i18n.js");
await i18n.initializeTranslations();
const sqlInit = (await import("../../server/src/services/sql_init.js")).default;
await sqlInit.createInitialDatabase(true);
// Wait for becca to be loaded before importing data
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
// Build User Guide
console.log("Building User Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
// Build Developer Guide
console.log("Building Developer Guide...");
await importAndExportDocs(join(__dirname, DOCS_ROOT, "Developer Guide"), "developer-guide");
// Copy favicon.
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "user-guide", "favicon.ico"));
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
console.log("Documentation built successfully!");
}
export async function importData(path: string) {
const buffer = await createImportZip(path);
const importService = (await import("../../server/src/services/import/zip.js")).default;
const TaskContext = (await import("../../server/src/services/task_context.js")).default;
const context = new TaskContext("no-progress-reporting", "importNotes", null);
const becca = (await import("../../server/src/becca/becca.js")).default;
const rootNote = becca.getRoot();
if (!rootNote) {
throw new Error("Missing root note for import.");
}
return await importService.importZip(context, buffer, rootNote, {
preserveIds: true
});
}
async function createImportZip(path: string) {
const inputFile = "input.zip";
const archive = archiver("zip", {
zlib: { level: 0 }
});
console.log("Archive path is ", resolve(path))
archive.directory(path, "/");
const outputStream = fsExtra.createWriteStream(inputFile);
archive.pipe(outputStream);
archive.finalize();
await waitForStreamToFinish(outputStream);
try {
return await fsExtra.readFile(inputFile);
} finally {
await fsExtra.rm(inputFile);
}
}
function waitForStreamToFinish(stream: WriteStream) {
return new Promise<void>((res, rej) => {
stream.on("finish", () => res());
stream.on("error", (err) => rej(err));
});
}
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const { readZipFile, readContent } = (await import("@triliumnext/server/src/services/import/zip.js"));
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
// We ignore directories since they can appear out of order anyway.
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
const destPath = join(outputPath, entry.fileName);
const fileContent = await readContent(zip, entry);
await fsExtra.mkdirs(dirname(destPath));
await fs.writeFile(destPath, fileContent);
}
zip.readEntry();
});
}
export default async function buildDocs({ gitRootDir }: BuildContext) {
// Build the share theme.
execSync(`pnpm run --filter share-theme build`, {
stdio: "inherit",
cwd: gitRootDir
});
// Trigger the actual build.
await new Promise((res, rej) => {
cls.init(() => {
buildDocsInner()
.catch(rej)
.then(res);
});
});
}

View File

@@ -1,4 +0,0 @@
export default interface BuildContext {
gitRootDir: string;
baseDir: string;
}

View File

@@ -1,28 +0,0 @@
/**
* The front script API is accessible to code notes with the "JS (frontend)" language.
*
* The entire API is exposed as a single global: {@link api}
*
* @module Frontend Script API
*/
/**
* This file creates the entrypoint for TypeDoc that simulates the context from within a
* script note.
*
* Make sure to keep in line with frontend's `script_context.ts`.
*/
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
export type { default as FAttachment } from "../../client/src/entities/fattachment.js";
export type { default as FAttribute } from "../../client/src/entities/fattribute.js";
export type { default as FBranch } from "../../client/src/entities/fbranch.js";
export type { default as FNote } from "../../client/src/entities/fnote.js";
export type { Api } from "../../client/src/services/frontend_script_api.js";
export type { default as NoteContextAwareWidget } from "../../client/src/widgets/note_context_aware_widget.js";
export type { default as RightPanelWidget } from "../../client/src/widgets/right_panel_widget.js";
import FrontendScriptApi, { type Api } from "../../client/src/services/frontend_script_api.js";
//@ts-expect-error
export const api: Api = new FrontendScriptApi();

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="0; url=/user-guide">
<title>Redirecting...</title>
</head>
<body>
<p>If you are not redirected automatically, <a href="/user-guide">click here</a>.</p>
</body>
</html>

View File

@@ -1,30 +0,0 @@
import { join } from "path";
import BuildContext from "./context";
import buildSwagger from "./swagger";
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
import buildDocs from "./build-docs";
import buildScriptApi from "./script-api";
const context: BuildContext = {
gitRootDir: join(__dirname, "../../../"),
baseDir: join(__dirname, "../../../site")
};
async function main() {
// Clean input dir.
if (existsSync(context.baseDir)) {
rmSync(context.baseDir, { recursive: true });
}
mkdirSync(context.baseDir);
// Start building.
await buildDocs(context);
buildSwagger(context);
buildScriptApi(context);
// Copy index and 404 files.
cpSync(join(__dirname, "index.html"), join(context.baseDir, "index.html"));
cpSync(join(context.baseDir, "user-guide/404.html"), join(context.baseDir, "404.html"));
}
main();

View File

@@ -1,15 +0,0 @@
import { execSync } from "child_process";
import BuildContext from "./context";
import { join } from "path";
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
// Generate types
execSync(`pnpm typecheck`, { stdio: "inherit", cwd: gitRootDir });
for (const config of [ "backend", "frontend" ]) {
const outDir = join(baseDir, "script-api", config);
execSync(`pnpm typedoc --options typedoc.${config}.json --html "${outDir}"`, {
stdio: "inherit"
});
}
}

View File

@@ -1,32 +0,0 @@
import BuildContext from "./context";
import { join } from "path";
import { execSync } from "child_process";
import { mkdirSync } from "fs";
interface BuildInfo {
specPath: string;
outDir: string;
}
const DIR_PREFIX = "rest-api";
const buildInfos: BuildInfo[] = [
{
// Paths are relative to Git root.
specPath: "apps/server/internal.openapi.yaml",
outDir: `${DIR_PREFIX}/internal`
},
{
specPath: "apps/server/etapi.openapi.yaml",
outDir: `${DIR_PREFIX}/etapi`
}
];
export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
for (const { specPath, outDir } of buildInfos) {
const absSpecPath = join(gitRootDir, specPath);
const targetDir = join(baseDir, outDir);
mkdirSync(targetDir, { recursive: true });
execSync(`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`, { stdio: "inherit" });
}
}

View File

@@ -1,36 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020",
"outDir": "dist",
"strict": false,
"types": [
"node",
"express"
],
"rootDir": "src",
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo"
},
"include": [
"src/**/*.ts",
"../server/src/*.d.ts"
],
"exclude": [
"eslint.config.js",
"eslint.config.cjs",
"eslint.config.mjs"
],
"references": [
{
"path": "../server/tsconfig.app.json"
},
{
"path": "../desktop/tsconfig.app.json"
},
{
"path": "../client/tsconfig.app.json"
}
]
}

View File

@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"include": [],
"references": [
{
"path": "../server"
},
{
"path": "../client"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "https://typedoc.org/schema.json",
"name": "Trilium Backend API",
"entryPoints": [
"src/backend_script_entrypoint.ts"
],
"plugin": [
"typedoc-plugin-missing-exports"
]
}

View File

@@ -1,10 +0,0 @@
{
"$schema": "https://typedoc.org/schema.json",
"name": "Trilium Frontend API",
"entryPoints": [
"src/frontend_script_entrypoint.ts"
],
"plugin": [
"typedoc-plugin-missing-exports"
]
}

View File

@@ -1,86 +0,0 @@
{
"name": "@triliumnext/client-standalone",
"version": "1.0.0",
"description": "Standalone client for TriliumNext with SQLite WASM backend",
"private": true,
"license": "AGPL-3.0-only",
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"dev": "vite dev",
"test": "vitest",
"start-prod": "pnpm build && pnpm http-server dist -p 8888",
"coverage": "vitest --coverage"
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@sqlite.org/sqlite-wasm": "3.51.1-build2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/core": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.0.0",
"i18next": "25.7.3",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"js-sha1": "0.7.0",
"js-sha512": "0.9.0",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.4.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.1",
"react-i18next": "16.5.1",
"react-window": "2.2.3",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",
"@preact/preset-vite": "2.10.2",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.33",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "13.0.1",
"cross-env": "7.0.3",
"happy-dom": "20.0.11",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
}
}

View File

@@ -1,3 +0,0 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

View File

@@ -1,2 +0,0 @@
// Re-export desktop from client
export * from "../../client/src/desktop";

View File

@@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="favicon.ico">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<!-- <link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest"> -->
<title>Trilium Notes</title>
</head>
<body id="trilium-app">
<noscript><%= t("javascript-required") %></noscript>
<script>
// hide body to reduce flickering on the startup. This is done through JS and not CSS to not hide <noscript>
document.getElementsByTagName("body")[0].style.display = "none";
</script>
<div class="dropdown-menu dropdown-menu-sm" id="context-menu-container" style="display: none"></div>
<!-- Required for match the PWA's top bar color with the theme -->
<!-- This works even when the user directly changes --root-background in CSS -->
<div id="background-color-tracker" style="position: absolute; visibility: hidden; color: var(--root-background); transition: color 1ms;"></div>
<!-- Bootstrap (request server for required information) -->
<script src="./main.ts" type="module"></script>
<!-- Required for correct loading of scripts in Electron -->
<script>
if (typeof module === 'object') {window.module = module; module = undefined;}
</script>
</body>
</html>

View File

@@ -1,254 +0,0 @@
/**
* Browser-compatible router that mimics Express routing patterns.
* Supports path parameters (e.g., /api/notes/:noteId) and query strings.
*/
import { getContext, routes } from "@triliumnext/core";
export interface BrowserRequest {
method: string;
url: string;
path: string;
params: Record<string, string>;
query: Record<string, string | undefined>;
body?: unknown;
}
export interface BrowserResponse {
status: number;
headers: Record<string, string>;
body: ArrayBuffer | null;
}
export type RouteHandler = (req: BrowserRequest) => unknown | Promise<unknown>;
interface Route {
method: string;
pattern: RegExp;
paramNames: string[];
handler: RouteHandler;
}
const encoder = new TextEncoder();
/**
* Convert an Express-style path pattern to a RegExp.
* Supports :param syntax for path parameters.
*
* Examples:
* /api/notes/:noteId -> /^\/api\/notes\/([^\/]+)$/
* /api/notes/:noteId/revisions -> /^\/api\/notes\/([^\/]+)\/revisions$/
*/
function pathToRegex(path: string): { pattern: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
// Escape special regex characters except for :param patterns
const regexPattern = path
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
paramNames.push(paramName);
return '([^/]+)';
});
return {
pattern: new RegExp(`^${regexPattern}$`),
paramNames
};
}
/**
* Parse query string into an object.
*/
function parseQuery(search: string): Record<string, string | undefined> {
const query: Record<string, string | undefined> = {};
if (!search || search === '?') return query;
const params = new URLSearchParams(search);
for (const [key, value] of params) {
query[key] = value;
}
return query;
}
/**
* Convert a result to a JSON response.
*/
function jsonResponse(obj: unknown, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
const parsedObj = routes.convertEntitiesToPojo(obj);
const body = encoder.encode(JSON.stringify(parsedObj)).buffer as ArrayBuffer;
return {
status,
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
body
};
}
/**
* Convert a string to a text response.
*/
function textResponse(text: string, status = 200, extraHeaders: Record<string, string> = {}): BrowserResponse {
const body = encoder.encode(text).buffer as ArrayBuffer;
return {
status,
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
body
};
}
/**
* Browser router class that handles route registration and dispatching.
*/
export class BrowserRouter {
private routes: Route[] = [];
/**
* Register a route handler.
*/
register(method: string, path: string, handler: RouteHandler): void {
const { pattern, paramNames } = pathToRegex(path);
this.routes.push({
method: method.toUpperCase(),
pattern,
paramNames,
handler
});
}
/**
* Convenience methods for common HTTP methods.
*/
get(path: string, handler: RouteHandler): void {
this.register('GET', path, handler);
}
post(path: string, handler: RouteHandler): void {
this.register('POST', path, handler);
}
put(path: string, handler: RouteHandler): void {
this.register('PUT', path, handler);
}
patch(path: string, handler: RouteHandler): void {
this.register('PATCH', path, handler);
}
delete(path: string, handler: RouteHandler): void {
this.register('DELETE', path, handler);
}
/**
* Dispatch a request to the appropriate handler.
*/
async dispatch(method: string, urlString: string, body?: unknown, headers?: Record<string, string>): Promise<BrowserResponse> {
const url = new URL(urlString);
const path = url.pathname;
const query = parseQuery(url.search);
const upperMethod = method.toUpperCase();
// Parse JSON body if it's an ArrayBuffer and content-type suggests JSON
let parsedBody = body;
if (body instanceof ArrayBuffer && headers) {
const contentType = headers['content-type'] || headers['Content-Type'] || '';
if (contentType.includes('application/json')) {
try {
const text = new TextDecoder().decode(body);
if (text.trim()) {
parsedBody = JSON.parse(text);
}
} catch (e) {
console.warn('[Router] Failed to parse JSON body:', e);
// Keep original body if JSON parsing fails
parsedBody = body;
}
}
}
// Find matching route
for (const route of this.routes) {
if (route.method !== upperMethod) continue;
const match = path.match(route.pattern);
if (!match) continue;
// Extract path parameters
const params: Record<string, string> = {};
for (let i = 0; i < route.paramNames.length; i++) {
params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
}
const request: BrowserRequest = {
method: upperMethod,
url: urlString,
path,
params,
query,
body: parsedBody
};
try {
const result = await getContext().init(async () => await route.handler(request));
return this.formatResult(result);
} catch (error) {
return this.formatError(error, `Error handling ${method} ${path}`);
}
}
// No route matched
return textResponse(`Not found: ${method} ${path}`, 404);
}
/**
* Format a handler result into a response.
* Follows the same patterns as the server's apiResultHandler.
*/
private formatResult(result: unknown): BrowserResponse {
// Handle [statusCode, response] format
if (Array.isArray(result) && result.length > 0 && Number.isInteger(result[0])) {
const [statusCode, response] = result;
return jsonResponse(response, statusCode);
}
// Handle undefined (no content) - 204 should have no body
if (result === undefined) {
return {
status: 204,
headers: {},
body: null
};
}
// Default: JSON response with 200
return jsonResponse(result, 200);
}
/**
* Format an error into a response.
*/
private formatError(error: unknown, context: string): BrowserResponse {
console.error('[Router] Handler error:', context, error);
// Check for known error types
if (error && typeof error === 'object') {
const err = error as { constructor?: { name?: string }; message?: string };
if (err.constructor?.name === 'NotFoundError') {
return jsonResponse({ message: err.message || 'Not found' }, 404);
}
if (err.constructor?.name === 'ValidationError') {
return jsonResponse({ message: err.message || 'Validation error' }, 400);
}
}
// Generic error
const message = error instanceof Error ? error.message : String(error);
return jsonResponse({ message }, 500);
}
}
/**
* Create a new router instance.
*/
export function createRouter(): BrowserRouter {
return new BrowserRouter();
}

View File

@@ -1,92 +0,0 @@
/**
* Browser route definitions.
* This integrates with the shared route builder from @triliumnext/core.
*/
import { routes, icon_packs as iconPackService } from '@triliumnext/core';
import { BrowserRouter, type BrowserRequest } from './browser_router';
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
/**
* Wraps a core route handler to work with the BrowserRouter.
* Core handlers expect an Express-like request object with params, query, and body.
*/
function wrapHandler(handler: (req: any) => unknown) {
return (req: BrowserRequest) => {
// Create an Express-like request object
const expressLikeReq = {
params: req.params,
query: req.query,
body: req.body
};
return handler(expressLikeReq);
};
}
/**
* Creates an apiRoute function compatible with buildSharedApiRoutes.
* This bridges the core's route registration to the BrowserRouter.
*/
function createApiRoute(router: BrowserRouter) {
return (method: HttpMethod, path: string, handler: (req: any) => unknown) => {
router.register(method, path, wrapHandler(handler));
};
}
/**
* Register all API routes on the browser router using the shared builder.
*
* @param router - The browser router instance
*/
export function registerRoutes(router: BrowserRouter): void {
const apiRoute = createApiRoute(router);
routes.buildSharedApiRoutes(apiRoute);
apiRoute('get', '/bootstrap', bootstrapRoute);
// Dummy routes for compatibility.
apiRoute("get", "/api/script/widgets", () => []);
apiRoute("get", "/api/script/startup", () => []);
apiRoute("get", "/api/system-checks", () => ({ isCpuArchMismatch: false }))
apiRoute("get", "/api/search/:searchString", () => []);
apiRoute("get", "/api/search-templates", () => []);
apiRoute("get", "/api/autocomplete", () => []);
}
function bootstrapRoute() {
const iconPacks = iconPackService.getIconPacks();
const assetPath = ".";
return {
triliumVersion: "1.2.3",
assetPath,
baseApiUrl: "../api/",
themeCssUrl: null,
themeUseNextAsBase: "next",
device: "desktop",
headingStyle: "default",
layoutOrientation: "vertical",
platform: "web",
isElectron: false,
isStandalone: true,
hasNativeTitleBar: false,
hasBackgroundEffects: true,
currentLocale: { id: "en", rtl: false },
iconPackCss: iconPacks
.map(p => iconPackService.generateCss(p, p.builtin
? `${assetPath}/fonts/${p.fontAttachmentId}.${iconPackService.MIME_TO_EXTENSION_MAPPINGS[p.fontMime]}`
: `api/attachments/download/${p.fontAttachmentId}`))
.filter(Boolean)
.join("\n\n"),
iconRegistry: iconPackService.generateIconRegistry(iconPacks),
};
}
/**
* Create and configure a router with all routes registered.
*/
export function createConfiguredRouter(): BrowserRouter {
const router = new BrowserRouter();
registerRoutes(router);
return router;
}

View File

@@ -1,46 +0,0 @@
import { ExecutionContext } from "@triliumnext/core";
export default class BrowserExecutionContext implements ExecutionContext {
private store: Map<string, any> | null = null;
get<T = any>(key: string): T | undefined {
return this.store?.get(key);
}
set(key: string, value: any): void {
if (!this.store) {
throw new Error("ExecutionContext not initialized");
}
this.store.set(key, value);
}
reset(): void {
this.store = null;
}
init<T>(callback: () => T): T {
// Create a fresh context for this request
const prev = this.store;
this.store = new Map();
try {
const result = callback();
// If the result is a Promise, we need to handle cleanup after it resolves
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
const promise = result as unknown as Promise<any>;
return promise.finally(() => {
this.store = prev;
}) as T;
} else {
// Synchronous result, clean up immediately
this.store = prev;
return result;
}
} catch (error) {
// Always clean up on error (for synchronous errors)
this.store = prev;
throw error;
}
}
}

View File

@@ -1,145 +0,0 @@
import type { CryptoProvider } from "@triliumnext/core";
import { sha1 } from "js-sha1";
import { sha512 } from "js-sha512";
interface Cipher {
update(data: Uint8Array): Uint8Array;
final(): Uint8Array;
}
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/**
* Crypto provider for browser environments using the Web Crypto API.
*/
export default class BrowserCryptoProvider implements CryptoProvider {
createHash(algorithm: "sha1" | "sha512", content: string | Uint8Array): Uint8Array {
const data = typeof content === "string" ? content :
new TextDecoder().decode(content);
const hexHash = algorithm === "sha1" ? sha1(data) : sha512(data);
// Convert hex string to Uint8Array
const bytes = new Uint8Array(hexHash.length / 2);
for (let i = 0; i < hexHash.length; i += 2) {
bytes[i / 2] = parseInt(hexHash.substr(i, 2), 16);
}
return bytes;
}
createCipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
// Web Crypto API doesn't support streaming cipher like Node.js
// We need to implement a wrapper that collects data and encrypts on final()
return new WebCryptoCipher(algorithm, key, iv, "encrypt");
}
createDecipheriv(algorithm: "aes-128-cbc", key: Uint8Array, iv: Uint8Array): Cipher {
return new WebCryptoCipher(algorithm, key, iv, "decrypt");
}
randomBytes(size: number): Uint8Array {
const bytes = new Uint8Array(size);
crypto.getRandomValues(bytes);
return bytes;
}
randomString(length: number): string {
const bytes = this.randomBytes(length);
let result = "";
for (let i = 0; i < length; i++) {
result += CHARS[bytes[i] % CHARS.length];
}
return result;
}
}
/**
* A cipher implementation that wraps Web Crypto API.
* Note: This buffers all data until final() is called, which differs from
* Node.js's streaming cipher behavior.
*/
class WebCryptoCipher implements Cipher {
private chunks: Uint8Array[] = [];
private algorithm: string;
private key: Uint8Array;
private iv: Uint8Array;
private mode: "encrypt" | "decrypt";
private finalized = false;
constructor(
algorithm: "aes-128-cbc",
key: Uint8Array,
iv: Uint8Array,
mode: "encrypt" | "decrypt"
) {
this.algorithm = algorithm;
this.key = key;
this.iv = iv;
this.mode = mode;
}
update(data: Uint8Array): Uint8Array {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
// Buffer the data - Web Crypto doesn't support streaming
this.chunks.push(data);
// Return empty array since we process everything in final()
return new Uint8Array(0);
}
final(): Uint8Array {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
this.finalized = true;
// Web Crypto API is async, but we need sync behavior
// This is a fundamental limitation that requires architectural changes
// For now, throw an error directing users to use async methods
throw new Error(
"Synchronous cipher finalization not available in browser. " +
"The Web Crypto API is async-only. Use finalizeAsync() instead."
);
}
/**
* Async version that actually performs the encryption/decryption.
*/
async finalizeAsync(): Promise<Uint8Array> {
if (this.finalized) {
throw new Error("Cipher has already been finalized");
}
this.finalized = true;
// Concatenate all chunks
const totalLength = this.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const data = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of this.chunks) {
data.set(chunk, offset);
offset += chunk.length;
}
// Copy key and iv to ensure they're plain ArrayBuffer-backed
const keyBuffer = new Uint8Array(this.key);
const ivBuffer = new Uint8Array(this.iv);
// Import the key
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "AES-CBC" },
false,
[this.mode]
);
// Perform encryption/decryption
const result = this.mode === "encrypt"
? await crypto.subtle.encrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data)
: await crypto.subtle.decrypt({ name: "AES-CBC", iv: ivBuffer }, cryptoKey, data);
return new Uint8Array(result);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,92 +0,0 @@
import type { WebSocketMessage } from "@triliumnext/commons";
import type { MessagingProvider, MessageHandler } from "@triliumnext/core";
/**
* Messaging provider for browser Worker environments.
*
* This provider uses the Worker's postMessage API to communicate
* with the main thread. It's designed to be used inside a Web Worker
* that runs the core services.
*
* Message flow:
* - Outbound (worker → main): Uses self.postMessage() with type: "WS_MESSAGE"
* - Inbound (main → worker): Listens to onmessage for type: "WS_MESSAGE"
*/
export default class WorkerMessagingProvider implements MessagingProvider {
private messageHandlers: MessageHandler[] = [];
private isDisposed = false;
constructor() {
// Listen for incoming messages from the main thread
self.addEventListener("message", this.handleIncomingMessage);
console.log("[WorkerMessagingProvider] Initialized");
}
private handleIncomingMessage = (event: MessageEvent) => {
if (this.isDisposed) return;
const { type, message } = event.data || {};
if (type === "WS_MESSAGE" && message) {
// Dispatch to all registered handlers
for (const handler of this.messageHandlers) {
try {
handler(message as WebSocketMessage);
} catch (e) {
console.error("[WorkerMessagingProvider] Error in message handler:", e);
}
}
}
};
/**
* Send a message to all clients (in this case, the main thread).
* The main thread is responsible for further distribution if needed.
*/
sendMessageToAllClients(message: WebSocketMessage): void {
if (this.isDisposed) {
console.warn("[WorkerMessagingProvider] Cannot send message - provider is disposed");
return;
}
try {
self.postMessage({
type: "WS_MESSAGE",
message
});
} catch (e) {
console.error("[WorkerMessagingProvider] Error sending message:", e);
}
}
/**
* Subscribe to incoming messages from the main thread.
*/
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.push(handler);
return () => {
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
};
}
/**
* Get the number of connected "clients".
* In worker context, there's always exactly 1 client (the main thread).
*/
getClientCount(): number {
return this.isDisposed ? 0 : 1;
}
/**
* Clean up resources.
*/
dispose(): void {
if (this.isDisposed) return;
this.isDisposed = true;
self.removeEventListener("message", this.handleIncomingMessage);
this.messageHandlers = [];
console.log("[WorkerMessagingProvider] Disposed");
}
}

View File

@@ -1,615 +0,0 @@
import type { DatabaseProvider, RunResult, Statement, Transaction } from "@triliumnext/core";
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
import type { BindableValue } from "@sqlite.org/sqlite-wasm";
import demoDbSql from "./db.sql?raw";
// Type definitions for SQLite WASM (the library doesn't export these directly)
type Sqlite3Module = Awaited<ReturnType<typeof sqlite3InitModule>>;
type Sqlite3Database = InstanceType<Sqlite3Module["oo1"]["DB"]>;
type Sqlite3PreparedStatement = ReturnType<Sqlite3Database["prepare"]>;
/**
* Wraps an SQLite WASM PreparedStatement to match the Statement interface
* expected by trilium-core.
*/
class WasmStatement implements Statement {
private isRawMode = false;
private isPluckMode = false;
private isFinalized = false;
constructor(
private stmt: Sqlite3PreparedStatement,
private db: Sqlite3Database
) {}
run(...params: unknown[]): RunResult {
if (this.isFinalized) {
throw new Error("Cannot call run() on finalized statement");
}
this.bindParams(params);
try {
// Use step() and then reset instead of stepFinalize()
// This allows the statement to be reused
this.stmt.step();
const changes = this.db.changes();
this.stmt.reset();
return {
changes,
lastInsertRowid: 0 // Would need sqlite3_last_insert_rowid for this
};
} catch (e) {
// Reset on error to allow reuse
this.stmt.reset();
throw e;
}
}
get(params: unknown): unknown {
if (this.isFinalized) {
throw new Error("Cannot call get() on finalized statement");
}
this.bindParams(Array.isArray(params) ? params : params !== undefined ? [params] : []);
try {
if (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value
const row = this.stmt.get([]);
return Array.isArray(row) && row.length > 0 ? row[0] : undefined;
}
return this.isRawMode ? this.stmt.get([]) : this.stmt.get({});
}
return undefined;
} finally {
this.stmt.reset();
}
}
all(...params: unknown[]): unknown[] {
if (this.isFinalized) {
throw new Error("Cannot call all() on finalized statement");
}
this.bindParams(params);
const results: unknown[] = [];
try {
while (this.stmt.step()) {
if (this.isPluckMode) {
// In pluck mode, return only the first column value for each row
const row = this.stmt.get([]);
if (Array.isArray(row) && row.length > 0) {
results.push(row[0]);
}
} else {
results.push(this.isRawMode ? this.stmt.get([]) : this.stmt.get({}));
}
}
return results;
} finally {
this.stmt.reset();
}
}
iterate(...params: unknown[]): IterableIterator<unknown> {
if (this.isFinalized) {
throw new Error("Cannot call iterate() on finalized statement");
}
this.bindParams(params);
const stmt = this.stmt;
const isRaw = this.isRawMode;
const isPluck = this.isPluckMode;
return {
[Symbol.iterator]() {
return this;
},
next(): IteratorResult<unknown> {
if (stmt.step()) {
if (isPluck) {
const row = stmt.get([]);
const value = Array.isArray(row) && row.length > 0 ? row[0] : undefined;
return { value, done: false };
}
return { value: isRaw ? stmt.get([]) : stmt.get({}), done: false };
}
stmt.reset();
return { value: undefined, done: true };
}
};
}
raw(toggleState?: boolean): this {
// In raw mode, rows are returned as arrays instead of objects
// If toggleState is undefined, enable raw mode (better-sqlite3 behavior)
this.isRawMode = toggleState !== undefined ? toggleState : true;
return this;
}
pluck(toggleState?: boolean): this {
// In pluck mode, only the first column of each row is returned
// If toggleState is undefined, enable pluck mode (better-sqlite3 behavior)
this.isPluckMode = toggleState !== undefined ? toggleState : true;
return this;
}
private bindParams(params: unknown[]): void {
this.stmt.clearBindings();
if (params.length === 0) {
return;
}
// Handle single object with named parameters
if (params.length === 1 && typeof params[0] === "object" && params[0] !== null && !Array.isArray(params[0])) {
const inputBindings = params[0] as { [paramName: string]: BindableValue };
// SQLite WASM expects parameter names to include the prefix (@ : or $)
// better-sqlite3 automatically maps unprefixed names to @name
// We need to add the @ prefix for compatibility
const bindings: { [paramName: string]: BindableValue } = {};
for (const [key, value] of Object.entries(inputBindings)) {
// If the key already has a prefix, use it as-is
if (key.startsWith('@') || key.startsWith(':') || key.startsWith('$')) {
bindings[key] = value;
} else {
// Add @ prefix to match better-sqlite3 behavior
bindings[`@${key}`] = value;
}
}
this.stmt.bind(bindings);
} else {
// Handle positional parameters - flatten and cast to BindableValue[]
const flatParams = params.flat() as BindableValue[];
if (flatParams.length > 0) {
this.stmt.bind(flatParams);
}
}
}
finalize(): void {
if (!this.isFinalized) {
try {
this.stmt.finalize();
} catch (e) {
console.warn("Error finalizing SQLite statement:", e);
} finally {
this.isFinalized = true;
}
}
}
}
/**
* SQLite database provider for browser environments using SQLite WASM.
*
* This provider wraps the official @sqlite.org/sqlite-wasm package to provide
* a DatabaseProvider implementation compatible with trilium-core.
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm(); // Initialize SQLite WASM module
* provider.loadFromMemory(); // Open an in-memory database
* // or
* provider.loadFromBuffer(existingDbBuffer); // Load from existing data
* ```
*/
export default class BrowserSqlProvider implements DatabaseProvider {
private db?: Sqlite3Database;
private sqlite3?: Sqlite3Module;
private _inTransaction = false;
private initPromise?: Promise<void>;
private initError?: Error;
private statementCache: Map<string, WasmStatement> = new Map();
// OPFS state tracking
private opfsDbPath?: string;
/**
* Get the SQLite WASM module version info.
* Returns undefined if the module hasn't been initialized yet.
*/
get version(): { libVersion: string; sourceId: string } | undefined {
return this.sqlite3?.version;
}
/**
* Initialize the SQLite WASM module.
* This must be called before using any database operations.
* Safe to call multiple times - subsequent calls return the same promise.
*
* @returns A promise that resolves when the module is initialized
* @throws Error if initialization fails
*/
async initWasm(): Promise<void> {
// Return existing promise if already initializing/initialized
if (this.initPromise) {
return this.initPromise;
}
// Fail fast if we already tried and failed
if (this.initError) {
throw this.initError;
}
this.initPromise = this.doInitWasm();
return this.initPromise;
}
private async doInitWasm(): Promise<void> {
try {
console.log("[BrowserSqlProvider] Initializing SQLite WASM...");
const startTime = performance.now();
this.sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error,
});
const initTime = performance.now() - startTime;
console.log(
`[BrowserSqlProvider] SQLite WASM initialized in ${initTime.toFixed(2)}ms:`,
this.sqlite3.version.libVersion
);
} catch (e) {
this.initError = e instanceof Error ? e : new Error(String(e));
console.error("[BrowserSqlProvider] SQLite WASM initialization failed:", this.initError);
throw this.initError;
}
}
/**
* Check if the SQLite WASM module has been initialized.
*/
get isInitialized(): boolean {
return this.sqlite3 !== undefined;
}
// ==================== OPFS Support ====================
/**
* Check if the OPFS VFS is available.
* This requires:
* - Running in a Worker context
* - Browser support for OPFS APIs
* - COOP/COEP headers sent by the server (for SharedArrayBuffer)
*
* @returns true if OPFS VFS is available for use
*/
isOpfsAvailable(): boolean {
this.ensureSqlite3();
// SQLite WASM automatically installs the OPFS VFS if the environment supports it
// We can check for its presence via sqlite3_vfs_find or the OpfsDb class
return this.sqlite3!.oo1.OpfsDb !== undefined;
}
/**
* Load or create a database stored in OPFS for persistent storage.
* The database will persist across browser sessions.
*
* Requires COOP/COEP headers to be set by the server:
* - Cross-Origin-Opener-Policy: same-origin
* - Cross-Origin-Embedder-Policy: require-corp
*
* @param path - The path for the database file in OPFS (e.g., "/trilium.db")
* Paths without a leading slash are treated as relative to OPFS root.
* Leading directories are created automatically.
* @param options - Additional options
* @throws Error if OPFS VFS is not available
*
* @example
* ```typescript
* const provider = new BrowserSqlProvider();
* await provider.initWasm();
* if (provider.isOpfsAvailable()) {
* provider.loadFromOpfs("/my-database.db");
* } else {
* console.warn("OPFS not available, using in-memory database");
* provider.loadFromMemory();
* }
* ```
*/
loadFromOpfs(path: string, options: { createIfNotExists?: boolean } = {}): void {
this.ensureSqlite3();
if (!this.isOpfsAvailable()) {
throw new Error(
"OPFS VFS is not available. This requires:\n" +
"1. Running in a Worker context\n" +
"2. Browser support for OPFS (Chrome 102+, Firefox 111+, Safari 17+)\n" +
"3. COOP/COEP headers from the server:\n" +
" Cross-Origin-Opener-Policy: same-origin\n" +
" Cross-Origin-Embedder-Policy: require-corp"
);
}
console.log(`[BrowserSqlProvider] Loading database from OPFS: ${path}`);
const startTime = performance.now();
try {
// OpfsDb automatically creates directories in the path
// Mode 'c' = create if not exists
const mode = options.createIfNotExists !== false ? 'c' : '';
this.db = new this.sqlite3!.oo1.OpfsDb(path, mode);
this.opfsDbPath = path;
// Configure the database for OPFS
// Note: WAL mode requires exclusive locking in OPFS environment
this.db.exec("PRAGMA journal_mode = DELETE");
this.db.exec("PRAGMA synchronous = NORMAL");
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] OPFS database loaded in ${loadTime.toFixed(2)}ms`);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error(`[BrowserSqlProvider] Failed to load OPFS database: ${error.message}`);
throw error;
}
}
/**
* Check if the currently open database is stored in OPFS.
*/
get isUsingOpfs(): boolean {
return this.opfsDbPath !== undefined;
}
/**
* Get the OPFS path of the currently open database.
* Returns undefined if not using OPFS.
*/
get currentOpfsPath(): string | undefined {
return this.opfsDbPath;
}
/**
* Check if the database has been initialized with a schema.
* This is a simple sanity check that looks for the existence of core tables.
*
* @returns true if the database appears to be initialized
*/
isDbInitialized(): boolean {
this.ensureDb();
// Check if the 'notes' table exists (a core table that must exist in an initialized DB)
const tableExists = this.db!.selectValue(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'notes'"
);
return tableExists !== undefined;
}
// ==================== End OPFS Support ====================
loadFromFile(_path: string, _isReadOnly: boolean): void {
// Browser environment doesn't have direct file system access.
// Use OPFS for persistent storage.
throw new Error(
"loadFromFile is not supported in browser environment. " +
"Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " +
"or loadFromOpfs() for persistent storage."
);
}
/**
* Create an empty in-memory database.
* Data will be lost when the page is closed.
*
* For persistent storage, use loadFromOpfs() instead.
* To load demo data, call initializeDemoDatabase() after this.
*/
loadFromMemory(): void {
this.ensureSqlite3();
console.log("[BrowserSqlProvider] Creating in-memory database...");
const startTime = performance.now();
this.db = new this.sqlite3!.oo1.DB(":memory:", "c");
this.opfsDbPath = undefined; // Not using OPFS
this.db.exec("PRAGMA journal_mode = WAL");
// Initialize with demo data for in-memory databases
// (since they won't persist anyway)
this.initializeDemoDatabase();
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] In-memory database created in ${loadTime.toFixed(2)}ms`);
}
/**
* Initialize the database with demo/starter data.
* This should only be called once when creating a new database.
*
* For OPFS databases, this is called automatically only if the database
* doesn't already exist.
*/
initializeDemoDatabase(): void {
this.ensureDb();
console.log("[BrowserSqlProvider] Initializing database with demo data...");
const startTime = performance.now();
this.db!.exec(demoDbSql);
const loadTime = performance.now() - startTime;
console.log(`[BrowserSqlProvider] Demo data loaded in ${loadTime.toFixed(2)}ms`);
}
loadFromBuffer(buffer: Uint8Array): void {
this.ensureSqlite3();
// SQLite WASM can deserialize a database from a byte array
const p = this.sqlite3!.wasm.allocFromTypedArray(buffer);
try {
this.db = new this.sqlite3!.oo1.DB({ filename: ":memory:", flags: "c" });
this.opfsDbPath = undefined; // Not using OPFS
const rc = this.sqlite3!.capi.sqlite3_deserialize(
this.db.pointer!,
"main",
p,
buffer.byteLength,
buffer.byteLength,
this.sqlite3!.capi.SQLITE_DESERIALIZE_FREEONCLOSE |
this.sqlite3!.capi.SQLITE_DESERIALIZE_RESIZEABLE
);
if (rc !== 0) {
throw new Error(`Failed to deserialize database: ${rc}`);
}
} catch (e) {
this.sqlite3!.wasm.dealloc(p);
throw e;
}
}
backup(_destinationFile: string): void {
// In browser, we can serialize the database to a byte array
// For actual file backup, we'd need to use File System Access API or download
throw new Error(
"backup to file is not supported in browser environment. " +
"Use serialize() to get the database as a Uint8Array instead."
);
}
/**
* Serialize the database to a byte array.
* This can be used to save the database to IndexedDB, download it, etc.
*/
serialize(): Uint8Array {
this.ensureDb();
// Use the convenience wrapper which handles all the memory management
return this.sqlite3!.capi.sqlite3_js_db_export(this.db!);
}
prepare(query: string): Statement {
this.ensureDb();
// Check if we already have this statement cached
if (this.statementCache.has(query)) {
return this.statementCache.get(query)!;
}
// Create new statement and cache it
const stmt = this.db!.prepare(query);
const wasmStatement = new WasmStatement(stmt, this.db!);
this.statementCache.set(query, wasmStatement);
return wasmStatement;
}
transaction<T>(func: (statement: Statement) => T): Transaction {
this.ensureDb();
const self = this;
let savepointCounter = 0;
// Helper function to execute within a transaction
const executeTransaction = (beginStatement: string, ...args: unknown[]): T => {
// If we're already in a transaction, use SAVEPOINTs for nesting
// This mimics better-sqlite3's behavior
if (self._inTransaction) {
const savepointName = `sp_${++savepointCounter}_${Date.now()}`;
self.db!.exec(`SAVEPOINT ${savepointName}`);
try {
const result = func.apply(null, args as [Statement]);
self.db!.exec(`RELEASE SAVEPOINT ${savepointName}`);
return result;
} catch (e) {
self.db!.exec(`ROLLBACK TO SAVEPOINT ${savepointName}`);
throw e;
}
}
// Not in a transaction, start a new one
self._inTransaction = true;
self.db!.exec(beginStatement);
try {
const result = func.apply(null, args as [Statement]);
self.db!.exec("COMMIT");
return result;
} catch (e) {
self.db!.exec("ROLLBACK");
throw e;
} finally {
self._inTransaction = false;
}
};
// Create the transaction function that acts like better-sqlite3's Transaction interface
// In better-sqlite3, the transaction function is callable and has .deferred(), .immediate(), etc.
const transactionWrapper = Object.assign(
// Default call executes with BEGIN (same as immediate)
(...args: unknown[]): T => executeTransaction("BEGIN", ...args),
{
// Deferred transaction - locks acquired on first data access
deferred: (...args: unknown[]): T => executeTransaction("BEGIN DEFERRED", ...args),
// Immediate transaction - acquires write lock immediately
immediate: (...args: unknown[]): T => executeTransaction("BEGIN IMMEDIATE", ...args),
// Exclusive transaction - exclusive lock
exclusive: (...args: unknown[]): T => executeTransaction("BEGIN EXCLUSIVE", ...args),
// Default is same as calling directly
default: (...args: unknown[]): T => executeTransaction("BEGIN", ...args)
}
);
return transactionWrapper as unknown as Transaction;
}
get inTransaction(): boolean {
return this._inTransaction;
}
exec(query: string): void {
this.ensureDb();
this.db!.exec(query);
}
close(): void {
// Clean up all cached statements first
for (const statement of this.statementCache.values()) {
try {
statement.finalize();
} catch (e) {
// Ignore errors during cleanup
console.warn("Error finalizing statement during cleanup:", e);
}
}
this.statementCache.clear();
if (this.db) {
this.db.close();
this.db = undefined;
}
// Reset OPFS state
this.opfsDbPath = undefined;
}
/**
* Get the number of rows changed by the last INSERT, UPDATE, or DELETE statement.
*/
changes(): number {
this.ensureDb();
return this.db!.changes();
}
/**
* Check if the database is currently open.
*/
isOpen(): boolean {
return this.db !== undefined && this.db.isOpen();
}
private ensureSqlite3(): void {
if (!this.sqlite3) {
throw new Error(
"SQLite WASM module not initialized. Call initialize() first with the sqlite3 module."
);
}
}
private ensureDb(): void {
this.ensureSqlite3();
if (!this.db) {
throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first.");
}
}
}

View File

@@ -1,89 +0,0 @@
// public/local-bridge.js
let localWorker: Worker | null = null;
const pending = new Map();
export function startLocalServerWorker() {
if (localWorker) return localWorker;
localWorker = new Worker(new URL("./local-server-worker.js", import.meta.url), { type: "module" });
// Handle worker errors during initialization
localWorker.onerror = (event) => {
console.error("[LocalBridge] Worker error:", event);
// Reject all pending requests
for (const [id, resolver] of pending) {
resolver.reject(new Error(`Worker error: ${event.message}`));
}
pending.clear();
};
localWorker.onmessage = (event) => {
const msg = event.data;
// Handle worker error reports
if (msg?.type === "WORKER_ERROR") {
console.error("[LocalBridge] Worker reported error:", msg.error);
// Reject all pending requests with the error
for (const [id, resolver] of pending) {
resolver.reject(new Error(msg.error?.message || "Unknown worker error"));
}
pending.clear();
return;
}
if (!msg || msg.type !== "LOCAL_RESPONSE") return;
const { id, response, error } = msg;
const resolver = pending.get(id);
if (!resolver) return;
pending.delete(id);
if (error) resolver.reject(new Error(error));
else resolver.resolve(response);
};
return localWorker;
}
export function attachServiceWorkerBridge() {
navigator.serviceWorker.addEventListener("message", async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_FETCH") return;
const port = event.ports && event.ports[0];
if (!port) return;
try {
startLocalServerWorker();
const id = msg.id;
const req = msg.request;
const response = await new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
// Transfer body to worker for efficiency (if present)
localWorker.postMessage({
type: "LOCAL_REQUEST",
id,
request: req
}, req.body ? [req.body] : []);
});
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id,
response
}, response.body ? [response.body] : []);
} catch (e) {
port.postMessage({
type: "LOCAL_FETCH_RESPONSE",
id: msg.id,
response: {
status: 500,
headers: { "content-type": "text/plain; charset=utf-8" },
body: new TextEncoder().encode(String(e?.message || e)).buffer
}
});
}
});
}

View File

@@ -1,222 +0,0 @@
// public/local-server-worker.js
// This will eventually import your core server and DB provider.
// import { createCoreServer } from "@trilium/core"; (bundled)
import BrowserExecutionContext from './lightweight/cls_provider';
import BrowserCryptoProvider from './lightweight/crypto_provider';
import BrowserSqlProvider from './lightweight/sql_provider';
import WorkerMessagingProvider from './lightweight/messaging_provider';
import { BrowserRouter } from './lightweight/browser_router';
import { createConfiguredRouter } from './lightweight/browser_routes';
// Global error handlers - MUST be set up before any async imports
self.onerror = (message, source, lineno, colno, error) => {
console.error("[Worker] Uncaught error:", message, source, lineno, colno, error);
// Try to notify the main thread about the error
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(message),
source,
lineno,
colno,
stack: error?.stack
}
});
} catch (e) {
// Can't even post message, just log
console.error("[Worker] Failed to report error:", e);
}
return false; // Don't suppress the error
};
self.onunhandledrejection = (event) => {
console.error("[Worker] Unhandled rejection:", event.reason);
try {
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(event.reason?.message || event.reason),
stack: event.reason?.stack
}
});
} catch (e) {
console.error("[Worker] Failed to report rejection:", e);
}
};
console.log("[Worker] Error handlers installed");
// Shared SQL provider instance
const sqlProvider = new BrowserSqlProvider();
// Messaging provider for worker-to-main-thread communication
const messagingProvider = new WorkerMessagingProvider();
// Core module, router, and initialization state
let coreModule: typeof import("@triliumnext/core") | null = null;
let router: BrowserRouter | null = null;
let initPromise: Promise<void> | null = null;
let initError: Error | null = null;
/**
* Initialize SQLite WASM and load the core module.
* This happens once at worker startup.
*/
async function initialize(): Promise<void> {
if (initPromise) {
return initPromise; // Already initializing
}
if (initError) {
throw initError; // Failed before, don't retry
}
initPromise = (async () => {
try {
console.log("[Worker] Initializing SQLite WASM...");
await sqlProvider.initWasm();
// Try to use OPFS for persistent storage
if (sqlProvider.isOpfsAvailable()) {
console.log("[Worker] OPFS available, loading persistent database...");
sqlProvider.loadFromOpfs("/trilium.db");
// Check if database is initialized (schema exists)
if (!sqlProvider.isDbInitialized()) {
console.log("[Worker] Database not initialized, loading demo data...");
sqlProvider.initializeDemoDatabase();
console.log("[Worker] Demo data loaded");
} else {
console.log("[Worker] Existing initialized database loaded");
}
} else {
// Fall back to in-memory database (non-persistent)
console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)");
console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server");
sqlProvider.loadFromMemory();
}
console.log("[Worker] Database loaded");
console.log("[Worker] Loading @triliumnext/core...");
coreModule = await import("@triliumnext/core");
coreModule.initializeCore({
executionContext: new BrowserExecutionContext(),
crypto: new BrowserCryptoProvider(),
messaging: messagingProvider,
dbConfig: {
provider: sqlProvider,
isReadOnly: false,
onTransactionCommit: () => {
// No-op for now
},
onTransactionRollback: () => {
// No-op for now
}
}
});
console.log("[Worker] Supported routes", Object.keys(coreModule.routes));
// Create and configure the router
router = createConfiguredRouter();
console.log("[Worker] Router configured");
console.log("[Worker] Initializing becca...");
await coreModule.becca_loader.beccaLoaded;
console.log("[Worker] Initialization complete");
} catch (error) {
initError = error instanceof Error ? error : new Error(String(error));
console.error("[Worker] Initialization failed:", initError);
throw initError;
}
})();
return initPromise;
}
/**
* Ensure the worker is initialized before processing requests.
* Returns the router if initialization was successful.
*/
async function ensureInitialized() {
await initialize();
if (!router) {
throw new Error("Router not initialized");
}
return router;
}
const encoder = new TextEncoder();
function jsonResponse(obj: unknown, status = 200, extraHeaders = {}) {
const body = encoder.encode(JSON.stringify(obj)).buffer;
return {
status,
headers: { "content-type": "application/json; charset=utf-8", ...extraHeaders },
body
};
}
interface LocalRequest {
method: string;
url: string;
body?: unknown;
headers?: Record<string, string>;
}
// Main dispatch
async function dispatch(request: LocalRequest) {
const url = new URL(request.url);
console.log("[Worker] Dispatch:", url.pathname);
// Ensure initialization is complete and get the router
const appRouter = await ensureInitialized();
// Dispatch to the router
return appRouter.dispatch(request.method, request.url, request.body, request.headers);
}
// Start initialization immediately when the worker loads
console.log("[Worker] Starting initialization...");
initialize().catch(err => {
console.error("[Worker] Initialization failed:", err);
// Post error to main thread
self.postMessage({
type: "WORKER_ERROR",
error: {
message: String(err?.message || err),
stack: err?.stack
}
});
});
self.onmessage = async (event) => {
const msg = event.data;
if (!msg || msg.type !== "LOCAL_REQUEST") return;
const { id, request } = msg;
console.log("[Worker] Received LOCAL_REQUEST:", id, request.method, request.url);
try {
const response = await dispatch(request);
console.log("[Worker] Dispatch completed, sending response:", id);
// Transfer body back (if any) - use options object for proper typing
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
response
}, { transfer: response.body ? [response.body] : [] });
} catch (e) {
console.error("[Worker] Dispatch error:", e);
(self as unknown as Worker).postMessage({
type: "LOCAL_RESPONSE",
id,
error: String((e as Error)?.message || e)
});
}
};

View File

@@ -1,189 +0,0 @@
import { attachServiceWorkerBridge, startLocalServerWorker } from "./local-bridge.js";
async function waitForServiceWorkerControl(): Promise<void> {
if (!("serviceWorker" in navigator)) {
throw new Error("Service Worker not supported in this browser");
}
// If already controlling, we're good
if (navigator.serviceWorker.controller) {
console.log("[Bootstrap] Service worker already controlling");
return;
}
console.log("[Bootstrap] Waiting for service worker to take control...");
// Register service worker
const registration = await navigator.serviceWorker.register("./sw.js", { scope: "/" });
// Wait for it to be ready (installed + activated)
await navigator.serviceWorker.ready;
// Check if we're now controlling
if (navigator.serviceWorker.controller) {
console.log("[Bootstrap] Service worker now controlling");
return;
}
// If not controlling yet, we need to reload the page for SW to take control
// This is standard PWA behavior on first install
console.log("[Bootstrap] Service worker installed but not controlling yet - reloading page");
// Wait a tiny bit for SW to fully activate
await new Promise(resolve => setTimeout(resolve, 100));
// Reload to let SW take control
window.location.reload();
// Throw to stop execution (page will reload)
throw new Error("Reloading for service worker activation");
}
async function fetchWithRetry(url: string, maxRetries = 3, delayMs = 500): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
console.log(`[Bootstrap] Fetching ${url} (attempt ${attempt + 1}/${maxRetries})`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Check if response has content
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
throw new Error(`Invalid content-type: ${contentType || "none"}`);
}
return response;
} catch (err) {
lastError = err as Error;
console.warn(`[Bootstrap] Fetch attempt ${attempt + 1} failed:`, err);
if (attempt < maxRetries - 1) {
// Exponential backoff
const delay = delayMs * Math.pow(2, attempt);
console.log(`[Bootstrap] Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`Failed to fetch ${url} after ${maxRetries} attempts: ${lastError?.message}`);
}
async function bootstrap() {
/* fixes https://github.com/webpack/webpack/issues/10035 */
window.global = globalThis;
try {
// 1) Start local worker ASAP (so /bootstrap is fast)
startLocalServerWorker();
// 2) Bridge SW -> local worker
attachServiceWorkerBridge();
// 3) Wait for service worker to control the page (may reload on first install)
await waitForServiceWorkerControl();
// 4) Now fetch bootstrap - SW is guaranteed to intercept this
await setupGlob();
loadStylesheets();
loadIcons();
setBodyAttributes();
await loadScripts();
} catch (err) {
// If error is from reload, it will stop here (page reloads)
// Otherwise, show error to user
if (err instanceof Error && err.message.includes("Reloading")) {
// Page is reloading, do nothing
return;
}
console.error("[Bootstrap] Fatal error:", err);
document.body.innerHTML = `
<div style="padding: 40px; max-width: 600px; margin: 0 auto; font-family: system-ui, sans-serif;">
<h1 style="color: #d32f2f;">Failed to Initialize</h1>
<p>The application failed to start. Please check the browser console for details.</p>
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow: auto;">${err instanceof Error ? err.message : String(err)}</pre>
<button onclick="location.reload()" style="padding: 12px 24px; background: #1976d2; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
Reload Page
</button>
</div>
`;
document.body.style.display = "block";
}
}
async function setupGlob() {
const response = await fetchWithRetry("/bootstrap");
console.log("Service worker state", navigator.serviceWorker.controller);
console.log("Resp", response);
const json = await response.json();
console.log("Bootstrap", json);
window.glob = {
...json,
activeDialog: null
};
}
function loadStylesheets() {
const { assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
const cssToLoad = [];
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
if (themeCssUrl) {
cssToLoad.push(themeCssUrl);
}
if (themeUseNextAsBase === "next") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`)
} else if (themeUseNextAsBase === "next-dark") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`)
} else if (themeUseNextAsBase === "next-light") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`)
}
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
for (const href of cssToLoad) {
const linkEl = document.createElement("link");
linkEl.href = href;
linkEl.rel = "stylesheet";
document.body.appendChild(linkEl);
}
}
function loadIcons() {
const styleEl = document.createElement("style");
styleEl.innerText = window.glob.iconPackCss;
document.head.appendChild(styleEl);
}
function setBodyAttributes() {
const { device, headingStyle, layoutOrientation, platform, isElectron, hasNativeTitleBar, hasBackgroundEffects, currentLocale } = window.glob;
const classesToSet = [
device,
`heading-style-${headingStyle}`,
`layout-${layoutOrientation}`,
`platform-${platform}`,
isElectron && "isElectron",
hasNativeTitleBar && "native-titlebar",
hasBackgroundEffects && "background-effects"
].filter(Boolean);
for (const classToSet of classesToSet) {
document.body.classList.add(classToSet);
}
document.body.lang = currentLocale.id;
document.body.dir = currentLocale.rtl ? "rtl" : "ltr";
}
async function loadScripts() {
await import("./runtime.js");
await import("./desktop.js");
}
bootstrap();

View File

@@ -1,2 +0,0 @@
// Re-export runtime from client
export * from "../../client/src/runtime";

View File

@@ -1,185 +0,0 @@
// public/sw.js
const VERSION = "localserver-v1.4";
const STATIC_CACHE = `static-${VERSION}`;
// Check if running in dev mode (passed via URL parameter)
const isDev = true;
if (isDev) {
console.log('[Service Worker] Running in DEV mode - caching disabled');
}
// Adjust these to your routes:
const LOCAL_FIRST_PREFIXES = [
"/bootstrap",
"/api/",
"/sync/",
"/search/"
];
// Optional: basic precache list (keep small; you can expand later)
const PRECACHE_URLS = [
// "/",
// "/index.html",
// "/manifest.webmanifest",
// "/favicon.ico",
];
self.addEventListener("install", (event) => {
event.waitUntil((async () => {
// Skip precaching in dev mode
if (!isDev) {
const cache = await caches.open(STATIC_CACHE);
await cache.addAll(PRECACHE_URLS);
}
self.skipWaiting();
})());
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
// Cleanup old caches
const keys = await caches.keys();
await Promise.all(keys.map((k) => (k === STATIC_CACHE ? Promise.resolve() : caches.delete(k))));
await self.clients.claim();
})());
});
function isLocalFirst(url) {
return LOCAL_FIRST_PREFIXES.some((p) => url.pathname.startsWith(p));
}
async function cacheFirst(request) {
// In dev mode, always bypass cache
if (isDev) {
return fetch(request);
}
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const fresh = await fetch(request);
// Cache only successful GETs
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
return fresh;
}
async function networkFirst(request) {
// In dev mode, always bypass cache
if (isDev) {
return fetch(request);
}
const cache = await caches.open(STATIC_CACHE);
try {
const fresh = await fetch(request);
// Cache only successful GETs
if (request.method === "GET" && fresh.ok) cache.put(request, fresh.clone());
return fresh;
} catch (error) {
// Fallback to cache if network fails
const cached = await cache.match(request);
if (cached) return cached;
throw error;
}
}
async function forwardToClientLocalServer(request, clientId) {
// Find a client to handle the request (prefer the initiating client if available)
let client = clientId ? await self.clients.get(clientId) : null;
if (!client) {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
client = all[0] || null;
}
// If no page is available, fall back to network
if (!client) return fetch(request);
const reqUrl = request.url;
const headersObj = {};
for (const [k, v] of request.headers.entries()) headersObj[k] = v;
const body = (request.method === "GET" || request.method === "HEAD")
? null
: await request.arrayBuffer();
const id = crypto.randomUUID();
const channel = new MessageChannel();
const responsePromise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Local server timeout"));
}, 30_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeout);
resolve(event.data);
};
channel.port1.onmessageerror = () => {
clearTimeout(timeout);
reject(new Error("Local server message error"));
};
});
// Send to the client with a reply port
client.postMessage({
type: "LOCAL_FETCH",
id,
request: {
url: reqUrl,
method: request.method,
headers: headersObj,
body // ArrayBuffer or null
}
}, [channel.port2]);
const localResp = await responsePromise;
if (!localResp || localResp.type !== "LOCAL_FETCH_RESPONSE" || localResp.id !== id) {
// Protocol mismatch; fall back
return fetch(request);
}
// localResp.response: { status, headers, body }
const { status, headers, body: respBody } = localResp.response;
const respHeaders = new Headers();
if (headers) {
for (const [k, v] of Object.entries(headers)) respHeaders.set(k, String(v));
}
return new Response(respBody ? respBody : null, {
status: status || 200,
headers: respHeaders
});
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Only handle same-origin
if (url.origin !== self.location.origin) return;
// HTML files: network-first to ensure updates are reflected immediately
if (event.request.mode === "navigate" || url.pathname.endsWith(".html")) {
event.respondWith(networkFirst(event.request));
return;
}
// Static assets: cache-first for performance
if (event.request.method === "GET" && !isLocalFirst(url)) {
event.respondWith(cacheFirst(event.request));
return;
}
// API-ish: local-first via bridge
if (isLocalFirst(url)) {
event.respondWith(forwardToClientLocalServer(event.request, event.clientId));
return;
}
// Default
event.respondWith(fetch(event.request));
});

View File

@@ -1,31 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
interface Window {
glob: {
assetPath: string;
themeCssUrl?: string;
themeUseNextAsBase?: string;
iconPackCss: string;
device: string;
headingStyle: string;
layoutOrientation: string;
platform: string;
isElectron: boolean;
hasNativeTitleBar: boolean;
hasBackgroundEffects: boolean;
currentLocale: {
id: string;
rtl: boolean;
};
activeDialog: any;
};
global: typeof globalThis;
}

View File

@@ -1,26 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"dom",
"dom.iterable"
],
"skipLibCheck": true,
"types": [
"vite/client"
],
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"include": [
"src/**/*",
"../client/src/**/*"
],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"../client/src/**/*.spec.ts",
"../client/src/**/*.test.ts"
]
}

View File

@@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.spec.json" }
]
}

View File

@@ -1,18 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": [
"ES2022",
"dom",
"dom.iterable"
],
"types": [
"vitest/globals",
"happy-dom"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -1,173 +0,0 @@
import preact from "@preact/preset-vite";
import { defineConfig } from 'vite';
import { join } from 'path';
import { viteStaticCopy } from 'vite-plugin-static-copy';
const assets = ["assets", "stylesheets", "fonts", "translations"];
const isDev = process.env.NODE_ENV === "development";
// Watch client files and trigger reload in development
const clientWatchPlugin = () => ({
name: 'client-watch',
configureServer(server: any) {
if (isDev) {
// Watch client source files (adjusted for new root)
server.watcher.add('../../client/src/**/*');
server.watcher.on('change', (file: string) => {
if (file.includes('../../client/src/')) {
server.ws.send({
type: 'full-reload'
});
}
});
}
}
});
// Always copy SQLite WASM files so they're available to the module
const sqliteWasmPlugin = viteStaticCopy({
targets: [
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3.wasm",
dest: "assets"
},
{
src: "../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm/sqlite3-opfs-async-proxy.js",
dest: "assets"
}
]
});
let plugins: any = [
preact({
babel: {
compact: !isDev
}
}),
sqliteWasmPlugin, // Always include SQLite WASM files
viteStaticCopy({
targets: assets.map((asset) => ({
src: `../../client/src/${asset}/*`,
dest: asset
})),
// Enable watching in development
...(isDev && {
watch: {
reloadPageOnChange: true
}
})
}),
// Watch client files for changes in development
...(isDev ? [clientWatchPlugin()] : [])
];
if (!isDev) {
plugins = [
...plugins,
viteStaticCopy({
structured: true,
targets: [
{
src: "../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
dest: "",
}
]
})
]
}
export default defineConfig(() => ({
root: join(__dirname, 'src'), // Set src as root so index.html is served from /
cacheDir: '../../../node_modules/.vite/apps/client-standalone',
base: "",
plugins,
publicDir: join(__dirname, 'public'),
resolve: {
alias: [
{
find: "react",
replacement: "preact/compat"
},
{
find: "react-dom",
replacement: "preact/compat"
},
{
find: "@client",
replacement: join(__dirname, "../client/src")
}
],
dedupe: [
"react",
"react-dom",
"preact",
"preact/compat",
"preact/hooks"
]
},
server: {
watch: {
// Watch workspace packages
ignored: ['!**/node_modules/@triliumnext/**'],
// Also watch client assets for live reload
usePolling: false,
interval: 100,
binaryInterval: 300
},
// Watch additional directories for changes
fs: {
allow: [
// Allow access to workspace root
'../../../',
// Explicitly allow client directory
'../../client/src/'
]
},
headers: {
// Required for SharedArrayBuffer which is needed by SQLite WASM OPFS VFS
// See: https://sqlite.org/wasm/doc/trunk/persistence.md#coop-coep
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp"
}
},
optimizeDeps: {
exclude: ['@sqlite.org/sqlite-wasm', '@triliumnext/core']
},
worker: {
format: "es" as const
},
commonjsOptions: {
transformMixedEsModules: true,
},
build: {
target: "esnext",
outDir: join(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
input: {
main: join(__dirname, 'src', 'index.html'),
sw: join(__dirname, 'src', 'sw.ts'),
'local-bridge': join(__dirname, 'src', 'local-bridge.ts'),
'local-server-worker': join(__dirname, 'src', 'local-server-worker.ts')
},
output: {
entryFileNames: (chunkInfo) => {
// Service worker and other workers should be at root level
if (chunkInfo.name === 'sw' || chunkInfo.name === 'local-server-worker') {
return '[name].js';
}
return 'src/[name].js';
},
chunkFileNames: "src/[name].js",
assetFileNames: "src/[name].[ext]"
}
}
},
test: {
environment: "happy-dom"
},
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
}
}));

View File

@@ -1,4 +1,5 @@
# The development license key for premium CKEditor features.
# Note: This key must only be used for the Trilium Notes project.
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3ODcyNzA0MDAsImp0aSI6IjkyMWE1MWNlLTliNDMtNGRlMC1iOTQwLTc5ZjM2MDBkYjg1NyIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOiJ0cmlsaXVtIiwiZmVhdHVyZXMiOlsiVFJJTElVTSJdLCJ2YyI6ImU4YzRhMjBkIn0.hny77p-U4-jTkoqbwPytrEar5ylGCWBN7Ez3SlB8i6_mJCBIeCSTOlVQk_JMiOEq3AGykUMHzWXzjdMFwgniOw
# Expires on: 2025-09-13
VITE_CKEDITOR_KEY=eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE3NTc3MjE1OTksImp0aSI6ImFiN2E0NjZmLWJlZGMtNDNiYy1iMzU4LTk0NGQ0YWJhY2I3ZiIsImRpc3RyaWJ1dGlvbkNoYW5uZWwiOlsic2giLCJkcnVwYWwiXSwid2hpdGVMYWJlbCI6dHJ1ZSwiZmVhdHVyZXMiOlsiRFJVUCIsIkNNVCIsIkRPIiwiRlAiLCJTQyIsIlRPQyIsIlRQTCIsIlBPRSIsIkNDIiwiTUYiLCJTRUUiLCJFQ0giLCJFSVMiXSwidmMiOiI1MzlkOWY5YyJ9.2rvKPql4hmukyXhEtWPZ8MLxKvzPIwzCdykO653g7IxRRZy2QJpeRszElZx9DakKYZKXekVRAwQKgHxwkgbE_w
VITE_CKEDITOR_ENABLE_INSPECTOR=false

View File

@@ -0,0 +1,5 @@
import baseConfig from "../../eslint.config.mjs";
export default [
...baseConfig
];

View File

@@ -1,68 +1,59 @@
{
"name": "@triliumnext/client",
"version": "0.101.1",
"version": "0.98.1",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
"author": {
"name": "Trilium Notes Team",
"email": "contact@eliandoran.me",
"url": "https://github.com/TriliumNext/Trilium"
},
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"test": "vitest",
"coverage": "vitest --coverage",
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.34.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/multimonth": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19",
"@fullcalendar/list": "6.1.19",
"@fullcalendar/multimonth": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@mermaid-js/layout-elk": "0.1.9",
"@mind-elixir/node-menu": "5.0.0",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dayjs": "1.11.18",
"dayjs-plugin-utc": "0.1.2",
"debounce": "2.2.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "17.0.0",
"i18next": "25.7.3",
"force-graph": "1.50.1",
"globals": "16.3.0",
"i18next": "25.4.2",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.27",
"katex": "0.16.22",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.4.0",
"marked": "16.2.1",
"mermaid": "11.10.1",
"mind-elixir": "5.0.6",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.28.1",
"react-i18next": "16.5.1",
"react-window": "2.2.3",
"reveal.js": "5.2.1",
"preact": "10.27.1",
"react-i18next": "15.7.3",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
@@ -72,14 +63,26 @@
"@preact/preset-vite": "2.10.2",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.33",
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/leaflet": "1.9.20",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.1",
"@types/tabulator-tables": "6.2.10",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.0.11",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
"vite-plugin-static-copy": "3.1.2"
},
"nx": {
"name": "client",
"targets": {
"serve": {
"dependsOn": [
"^build"
]
},
"circular-deps": {
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
}
}
}
}

View File

@@ -7,9 +7,6 @@
"display": "standalone",
"scope": "/",
"start_url": "/",
"display_override": [
"window-controls-overlay"
],
"icons": [
{
"src": "icon.png",

View File

@@ -1,41 +1,38 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { SqlExecuteResults } from "@triliumnext/commons";
import type { NativeImage, TouchBar } from "electron";
import { ColumnComponent } from "tabulator-tables";
import type { Attribute } from "../services/attribute_parser.js";
import froca from "../services/froca.js";
import { initLocale, t } from "../services/i18n.js";
import RootCommandExecutor from "./root_command_executor.js";
import Entrypoints from "./entrypoints.js";
import options from "../services/options.js";
import utils, { hasTouchBar } from "../services/utils.js";
import zoomComponent from "./zoom.js";
import TabManager from "./tab_manager.js";
import Component from "./component.js";
import keyboardActionsService from "../services/keyboard_actions.js";
import linkService, { type ViewScope } from "../services/link.js";
import type LoadResults from "../services/load_results.js";
import type { CreateNoteOpts } from "../services/note_create.js";
import options from "../services/options.js";
import toast from "../services/toast.js";
import utils, { hasTouchBar } from "../services/utils.js";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
import type RootContainer from "../widgets/containers/root_container.js";
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import Component from "./component.js";
import Entrypoints from "./entrypoints.js";
import MainTreeExecutors from "./main_tree_executors.js";
import MobileScreenSwitcherExecutor, { type Screen } from "./mobile_screen_switcher.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import RootCommandExecutor from "./root_command_executor.js";
import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js";
import { StartupChecks } from "./startup_checks.js";
import TabManager from "./tab_manager.js";
import { t, initLocale } from "../services/i18n.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
import type LoadResults from "../services/load_results.js";
import type { Attribute } from "../services/attribute_parser.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
import type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js";
import zoomComponent from "./zoom.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { StartupChecks } from "./startup_checks.js";
import type { CreateNoteOpts } from "../services/note_create.js";
import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js";
import { SqlExecuteResults } from "@triliumnext/commons";
interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer;
@@ -119,14 +116,14 @@ export type CommandMappings = {
openedFileUpdated: CommandData & {
entityType: string;
entityId: string;
lastModifiedMs?: number;
lastModifiedMs: number;
filePath: string;
};
focusAndSelectTitle: CommandData & {
isNewNote?: boolean;
};
showPromptDialog: PromptDialogOptions;
showInfoDialog: InfoProps;
showInfoDialog: ConfirmWithMessageOptions;
showConfirmDialog: ConfirmWithMessageOptions;
showRecentChanges: CommandData & { ancestorNoteId: string };
showImportDialog: CommandData & { noteId: string };
@@ -202,7 +199,7 @@ export type CommandMappings = {
resetLauncher: ContextMenuCommandData;
executeInActiveNoteDetailWidget: CommandData & {
callback: (value: ReactWrappedWidget) => void;
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
};
executeWithTextEditor: CommandData &
ExecuteCommandData<CKTextEditor> & {
@@ -214,19 +211,19 @@ export type CommandMappings = {
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
*/
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>;
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
addTextToActiveEditor: CommandData & {
text: string;
};
/** Works only in the electron context menu. */
replaceMisspelling: CommandData;
importMarkdownInline: CommandData;
showPasswordNotSet: CommandData;
showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & IncludeNoteOpts;
showAddLinkDialog: CommandData & AddLinkOpts;
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData;
@@ -266,14 +263,13 @@ export type CommandMappings = {
reEvaluateRightPaneVisibility: CommandData;
runActiveNote: CommandData;
scrollContainerTo: CommandData & {
scrollContainerToCommand: CommandData & {
position: number;
};
scrollToEnd: CommandData;
closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData;
openTodayNote: CommandData;
commandPalette: CommandData;
// Keyboard shortcuts
@@ -331,7 +327,6 @@ export type CommandMappings = {
exportAsPdf: CommandData;
openNoteExternally: CommandData;
openNoteCustom: CommandData;
openNoteOnServer: CommandData;
renderActiveNote: CommandData;
unhoist: CommandData;
reloadFrontendApp: CommandData;
@@ -382,8 +377,7 @@ export type CommandMappings = {
reloadTextEditor: CommandData;
chooseNoteType: CommandData & {
callback: ChooseNoteTypeCallback
};
customDownload: CommandData;
}
};
type EventMappings = {
@@ -448,8 +442,6 @@ type EventMappings = {
error: string;
};
searchRefreshed: { ntxId?: string | null };
textEditorRefreshed: { ntxId?: string | null, editor: CKTextEditor };
contentElRefreshed: { ntxId?: string | null, contentEl: HTMLElement };
hoistedNoteChanged: {
noteId: string;
ntxId: string | null;
@@ -474,11 +466,6 @@ type EventMappings = {
noteContextRemoved: {
ntxIds: string[];
};
contextDataChanged: {
noteContext: NoteContext;
key: string;
value: unknown;
};
exportSvg: { ntxId: string | null | undefined; };
exportPng: { ntxId: string | null | undefined; };
geoMapCreateChildNote: {
@@ -496,9 +483,14 @@ type EventMappings = {
relationMapResetPanZoom: { ntxId: string | null | undefined };
relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {ntxId: string | null | undefined};
showAddLinkDialog: AddLinkOpts;
showIncludeDialog: IncludeNoteOpts;
activeNoteChanged: {};
showAddLinkDialog: {
textTypeWidget: EditableTextTypeWidget;
text: string;
};
showIncludeDialog: {
textTypeWidget: EditableTextTypeWidget;
};
openBulkActionsDialog: {
selectedOrActiveNoteIds: string[];
};
@@ -506,10 +498,6 @@ type EventMappings = {
noteIds: string[];
};
refreshData: { ntxId: string | null | undefined };
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
}
};
export type EventListener<T extends EventNames> = {
@@ -662,7 +650,7 @@ export class AppContext extends Component {
}
getComponentByEl(el: HTMLElement) {
return $(el).closest("[data-component-id]").prop("component");
return $(el).closest(".component").prop("component");
}
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
@@ -677,10 +665,6 @@ export class AppContext extends Component {
this.beforeUnloadListeners.push(obj);
}
}
removeBeforeUnloadListener(listener: (() => boolean)) {
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
}
}
const appContext = new AppContext(window.glob.isMainWindow);
@@ -703,8 +687,10 @@ $(window).on("beforeunload", () => {
console.log(`Component ${component.componentId} is not finished saving its state.`);
allSaved = false;
}
} else if (!listener()) {
allSaved = false;
} else {
if (!listener()) {
allSaved = false;
}
}
}
@@ -714,7 +700,7 @@ $(window).on("beforeunload", () => {
}
});
$(window).on("hashchange", () => {
$(window).on("hashchange", function () {
const { notePath, ntxId, viewScope, searchString } = linkService.parseNavigationStateFromUrl(window.location.href);
if (notePath || ntxId) {

Some files were not shown because too many files have changed in this diff Show More