Compare commits
1 Commits
lightweigh
...
weblate-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d2984f1d |
@@ -1,6 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*.{js,ts,tsx,css}]
|
||||
[*.{js,ts,tsx}]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
|
||||
4
.github/actions/build-electron/action.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/actions/build-server/action.yml
vendored
@@ -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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
|
||||
2
.github/actions/report-size/action.yml
vendored
@@ -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
|
||||
|
||||
334
.github/copilot-instructions.md
vendored
@@ -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)
|
||||
6
.github/workflows/codeql.yml
vendored
@@ -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}}"
|
||||
|
||||
65
.github/workflows/deploy-app.yml
vendored
@@ -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 }}
|
||||
83
.github/workflows/deploy-docs.yml
vendored
@@ -1,4 +1,6 @@
|
||||
name: Deploy Documentation
|
||||
# GitHub Actions workflow for deploying MkDocs documentation to Cloudflare Pages
|
||||
# This workflow builds and deploys your MkDocs site when changes are pushed to main
|
||||
name: Deploy MkDocs Documentation
|
||||
|
||||
on:
|
||||
# Trigger on push to main branch
|
||||
@@ -9,9 +11,11 @@ on:
|
||||
# Only run when docs files change
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'apps/edit-docs/**'
|
||||
- 'apps/build-docs/**'
|
||||
- 'packages/share-theme/**'
|
||||
- 'README.md' # README is synced to docs/index.md
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- 'scripts/fix-mkdocs-structure.ts'
|
||||
|
||||
# Allow manual triggering from Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -23,13 +27,15 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'apps/edit-docs/**'
|
||||
- 'apps/build-docs/**'
|
||||
- 'packages/share-theme/**'
|
||||
- 'README.md' # README is synced to docs/index.md
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-docs.txt'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- 'scripts/fix-mkdocs-structure.ts'
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
name: Build and Deploy Documentation
|
||||
name: Build and Deploy MkDocs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -42,28 +48,73 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info and mkdocs-git-revision-date plugin
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'requirements-docs.txt'
|
||||
|
||||
- name: Install MkDocs and Dependencies
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements-docs.txt
|
||||
env:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: 1
|
||||
|
||||
# Setup pnpm before fixing docs structure
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
# Setup Node.js with pnpm
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Install Node.js dependencies for the TypeScript script
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Trigger build of documentation
|
||||
run: pnpm docs:build
|
||||
- name: Fix Documentation Structure
|
||||
run: |
|
||||
# Fix duplicate navigation entries by moving overview pages to index.md
|
||||
pnpm run chore:fix-mkdocs-structure
|
||||
|
||||
- name: Build MkDocs Site
|
||||
run: |
|
||||
# Build with strict mode but allow expected warnings
|
||||
mkdocs build --verbose || {
|
||||
EXIT_CODE=$?
|
||||
# Check if the only issue is expected warnings
|
||||
if mkdocs build 2>&1 | grep -E "WARNING.*(README|not found)" && \
|
||||
[ $(mkdocs build 2>&1 | grep -c "ERROR") -eq 0 ]; then
|
||||
echo "✅ Build succeeded with expected warnings"
|
||||
mkdocs build --verbose
|
||||
else
|
||||
echo "❌ Build failed with unexpected errors"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
}
|
||||
|
||||
- name: Fix HTML Links
|
||||
run: |
|
||||
# Remove .md extensions from links in generated HTML
|
||||
pnpm tsx ./scripts/fix-html-links.ts site
|
||||
|
||||
- name: Validate Built Site
|
||||
run: |
|
||||
# Basic validation that important files exist
|
||||
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"
|
||||
test -f site/sitemap.xml || (echo "ERROR: site/sitemap.xml not found" && exit 1)
|
||||
test -d site/assets || (echo "ERROR: site/assets directory not found" && exit 1)
|
||||
echo "✅ Site validation passed"
|
||||
|
||||
- name: Deploy
|
||||
uses: ./.github/actions/deploy-to-cloudflare-pages
|
||||
|
||||
10
.github/workflows/dev.yml
vendored
@@ -24,13 +24,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the 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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
needs:
|
||||
- test_dev
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -80,7 +80,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
|
||||
|
||||
28
.github/workflows/main-docker.yml
vendored
@@ -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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install npm dependencies
|
||||
@@ -86,12 +86,12 @@ jobs:
|
||||
|
||||
- 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,12 +141,12 @@ 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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -155,10 +155,6 @@ jobs:
|
||||
- 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
|
||||
|
||||
@@ -213,7 +209,7 @@ 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 }}
|
||||
path: /tmp/digests/*
|
||||
@@ -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-*
|
||||
|
||||
29
.github/workflows/nightly.yml
vendored
@@ -45,32 +45,19 @@ 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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- 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 +77,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.4
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
@@ -102,7 +89,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 +109,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 +118,7 @@ jobs:
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
uses: softprops/action-gh-release@v2.3.4
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
with:
|
||||
make_latest: false
|
||||
|
||||
64
.github/workflows/playwright.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- hotfix
|
||||
paths-ignore:
|
||||
- "apps/website/**"
|
||||
pull_request:
|
||||
@@ -14,74 +13,29 @@ 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
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps
|
||||
|
||||
- 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
|
||||
- run: pnpm --filter server-e2e e2e
|
||||
|
||||
- name: Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e report ${{ matrix.arch }}
|
||||
name: e2e report
|
||||
path: apps/server-e2e/test-output
|
||||
|
||||
- name: Kill the server
|
||||
if: always()
|
||||
run: pkill -f trilium || true
|
||||
|
||||
18
.github/workflows/release.yml
vendored
@@ -45,12 +45,12 @@ jobs:
|
||||
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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -73,7 +73,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 +91,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 +100,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 +114,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.4
|
||||
with:
|
||||
draft: false
|
||||
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
|
||||
|
||||
6
.github/workflows/website.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
pull-requests: write # For PR preview comments
|
||||
|
||||
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@v5
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
3
.gitignore
vendored
@@ -44,11 +44,8 @@ upload
|
||||
.rollup.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
/.direnv
|
||||
/result
|
||||
.svelte-kit
|
||||
|
||||
# docs
|
||||
site/
|
||||
apps/*/coverage
|
||||
scripts/translation/.language*.json
|
||||
|
||||
3
.vscode/extensions.json
vendored
@@ -9,6 +9,7 @@
|
||||
"tobermory.es6-string-html",
|
||||
"vitest.explorer",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"usernamehw.errorlens"
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
1
.vscode/i18n-ally-custom-framework.yml
vendored
@@ -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
|
||||
|
||||
11
.vscode/settings.json
vendored
@@ -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",
|
||||
@@ -36,11 +35,5 @@
|
||||
"docs/**/*.png": true,
|
||||
"apps/server/src/assets/doc_notes/**": true,
|
||||
"apps/edit-docs/demo/**": true
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "*", "severity": "warn" }
|
||||
]
|
||||
}
|
||||
}
|
||||
76
README.md
@@ -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
|
||||
|
||||
 
|
||||
@@ -16,18 +5,13 @@
|
||||

|
||||
[](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [](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.
|
||||
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
@@ -40,39 +24,39 @@ Our documentation is available in multiple formats:
|
||||
|
||||
### 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)
|
||||
- [Installation Instructions](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
|
||||
- [Docker Setup](./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)
|
||||
- [Basic Concepts and Features](./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)
|
||||
|
||||
## 🎁 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:
|
||||
|
||||
@@ -132,7 +116,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
|
||||
@@ -199,7 +183,7 @@ Trilium would not be possible without the technologies behind it:
|
||||
* [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)
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. 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
|
||||
|
||||
|
||||
7
_regroup/bin/create-anonymization-script.ts
Normal 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());
|
||||
7
_regroup/bin/export-schema.sh
Normal 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"
|
||||
@@ -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));
|
||||
16
_regroup/bin/push-docker-image.sh
Normal 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
|
||||
57
_regroup/bin/release-flatpack.sh
Normal 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
@@ -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
|
||||
|
Before Width: | Height: | Size: 383 B After Width: | Height: | Size: 383 B |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 357 B After Width: | Height: | Size: 357 B |
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 387 B |
|
Before Width: | Height: | Size: 734 B After Width: | Height: | Size: 734 B |
10
_regroup/entitlements.plist
Normal 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
@@ -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/*"
|
||||
]
|
||||
}
|
||||
);
|
||||
47
_regroup/eslint.format.config.js
Normal 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/*"
|
||||
]
|
||||
}
|
||||
];
|
||||
17
_regroup/integration-tests/auth.setup.ts
Normal 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 });
|
||||
});
|
||||
9
_regroup/integration-tests/duplicate.spec.ts
Normal 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();
|
||||
});
|
||||
18
_regroup/integration-tests/example.disabled.ts
Normal 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();
|
||||
});
|
||||
21
_regroup/integration-tests/settings.spec.ts
Normal 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();
|
||||
});
|
||||
18
_regroup/integration-tests/tree.spec.ts
Normal 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");
|
||||
});
|
||||
12
_regroup/integration-tests/update_check.spec.ts
Normal 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
@@ -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.56.0",
|
||||
"@stylistic/eslint-plugin": "5.4.0",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/node": "22.18.8",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.37.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.13",
|
||||
"typedoc-plugin-missing-exports": "4.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"appdmg": "0.6.6"
|
||||
}
|
||||
}
|
||||
9
_regroup/spec/etapi/app_info.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
*/
|
||||
10
_regroup/spec/etapi/backup.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
*/
|
||||
26
_regroup/spec/etapi/import.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
*/
|
||||
103
_regroup/spec/etapi/notes.ts
Normal 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.`);
|
||||
});
|
||||
});
|
||||
*/
|
||||
155
_regroup/spec/support/etapi.ts
Normal 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
|
||||
};
|
||||
22
_regroup/tsconfig.webpack.json
Normal 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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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, {});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default interface BuildContext {
|
||||
gitRootDir: string;
|
||||
baseDir: string;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../server"
|
||||
},
|
||||
{
|
||||
"path": "../client"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/*
|
||||
Cross-Origin-Opener-Policy: same-origin
|
||||
Cross-Origin-Embedder-Policy: require-corp
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export desktop from client
|
||||
export * from "../../client/src/desktop";
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export runtime from client
|
||||
export * from "../../client/src/runtime";
|
||||
@@ -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));
|
||||
});
|
||||
31
apps/client-standalone/src/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.spec.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}));
|
||||
5
apps/client/eslint.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [
|
||||
...baseConfig
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@triliumnext/client",
|
||||
"version": "0.101.1",
|
||||
"version": "0.99.1",
|
||||
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -12,57 +12,53 @@
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "9.37.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",
|
||||
"@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",
|
||||
"globals": "16.4.0",
|
||||
"i18next": "25.5.3",
|
||||
"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.23",
|
||||
"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.3.0",
|
||||
"mermaid": "11.12.0",
|
||||
"mind-elixir": "5.1.1",
|
||||
"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.2",
|
||||
"react-i18next": "16.0.0",
|
||||
"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 +68,13 @@
|
||||
"@preact/preset-vite": "2.10.2",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet": "1.9.20",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"@types/tabulator-tables": "6.2.11",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.0.11",
|
||||
"happy-dom": "19.0.2",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
"vite-plugin-static-copy": "3.1.3"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -126,7 +123,7 @@ export type CommandMappings = {
|
||||
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> = {
|
||||
@@ -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) {
|
||||
|
||||
@@ -57,18 +57,6 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a child component from this component's children array.
|
||||
* This is used for cleanup when a widget is unmounted to prevent event listener accumulation.
|
||||
*/
|
||||
removeChild(component: ChildT) {
|
||||
const index = this.children.indexOf(component);
|
||||
if (index !== -1) {
|
||||
this.children.splice(index, 1);
|
||||
component.parent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null | undefined {
|
||||
try {
|
||||
const callMethodPromise = this.initialized ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) : this.callMethod((this as any)[`${name}Event`], data);
|
||||
@@ -77,8 +65,8 @@ export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
|
||||
|
||||
// don't create promises if not needed (optimization)
|
||||
return callMethodPromise && childrenPromise ? Promise.all([callMethodPromise, childrenPromise]) : callMethodPromise || childrenPromise;
|
||||
} catch (e: unknown) {
|
||||
console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error`, e);
|
||||
} catch (e: any) {
|
||||
console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -159,16 +159,6 @@ export default class Entrypoints extends Component {
|
||||
this.openInWindowCommand({ notePath: "", hoistedNoteId: "root" });
|
||||
}
|
||||
|
||||
async openTodayNoteCommand() {
|
||||
const todayNote = await dateNoteService.getTodayNote();
|
||||
if (!todayNote) {
|
||||
console.warn("Missing today note.");
|
||||
return;
|
||||
}
|
||||
|
||||
await appContext.tabManager.openInSameTab(todayNote.noteId);
|
||||
}
|
||||
|
||||
async runActiveNoteCommand() {
|
||||
const noteContext = appContext.tabManager.getActiveContext();
|
||||
if (!noteContext) {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { closeActiveDialog } from "../services/dialog.js";
|
||||
import froca from "../services/froca.js";
|
||||
import hoistedNoteService from "../services/hoisted_note.js";
|
||||
import type { ViewScope } from "../services/link.js";
|
||||
import options from "../services/options.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import server from "../services/server.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import type { HeadingContext } from "../widgets/sidebar/TableOfContents.js";
|
||||
import appContext, { type EventData, type EventListener } from "./app_context.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import Component from "./component.js";
|
||||
import froca from "../services/froca.js";
|
||||
import hoistedNoteService from "../services/hoisted_note.js";
|
||||
import options from "../services/options.js";
|
||||
import type { ViewScope } from "../services/link.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { closeActiveDialog } from "../services/dialog.js";
|
||||
|
||||
export interface SetNoteOpts {
|
||||
triggerSwitchEvent?: unknown;
|
||||
@@ -23,31 +21,6 @@ export interface SetNoteOpts {
|
||||
|
||||
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
|
||||
|
||||
export type SaveState = "saved" | "saving" | "unsaved" | "error";
|
||||
|
||||
export interface NoteContextDataMap {
|
||||
toc: HeadingContext;
|
||||
pdfPages: {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
scrollToPage(page: number): void;
|
||||
requestThumbnail(page: number): void;
|
||||
};
|
||||
pdfAttachments: {
|
||||
attachments: PdfAttachment[];
|
||||
downloadAttachment(filename: string): void;
|
||||
};
|
||||
pdfLayers: {
|
||||
layers: PdfLayer[];
|
||||
toggleLayer(layerId: string, visible: boolean): void;
|
||||
};
|
||||
saveState: {
|
||||
state: SaveState;
|
||||
};
|
||||
}
|
||||
|
||||
type ContextDataKey = keyof NoteContextDataMap;
|
||||
|
||||
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
|
||||
ntxId: string | null;
|
||||
hoistedNoteId: string;
|
||||
@@ -58,13 +31,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
parentNoteId?: string | null;
|
||||
viewScope?: ViewScope;
|
||||
|
||||
/**
|
||||
* Metadata storage for UI components (e.g., table of contents, PDF page list, code outline).
|
||||
* This allows type widgets to publish data that sidebar/toolbar components can consume.
|
||||
* Data is automatically cleared when navigating to a different note.
|
||||
*/
|
||||
private contextData: Map<string, unknown> = new Map();
|
||||
|
||||
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
|
||||
super();
|
||||
|
||||
@@ -124,22 +90,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
this.viewScope = opts.viewScope;
|
||||
({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));
|
||||
|
||||
// Clear context data when switching notes and notify subscribers
|
||||
const oldKeys = Array.from(this.contextData.keys());
|
||||
this.contextData.clear();
|
||||
if (oldKeys.length > 0) {
|
||||
// Notify subscribers asynchronously to avoid blocking navigation
|
||||
window.setTimeout(() => {
|
||||
for (const key of oldKeys) {
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
this.saveToRecentNotes(resolvedNotePath);
|
||||
|
||||
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
|
||||
@@ -371,20 +321,14 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
return false;
|
||||
}
|
||||
|
||||
if (note.type === "search") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collections must always display a note list, even if no children.
|
||||
if (note.type === "book") {
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
}
|
||||
const viewType = note.getLabelValue("viewType") ?? "grid";
|
||||
if (!["list", "grid"].includes(viewType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!note.hasChildren()) {
|
||||
@@ -439,7 +383,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
* If no content could be determined `null` is returned instead.
|
||||
*/
|
||||
async getContentElement() {
|
||||
return this.timeout<JQuery<HTMLElement> | null>(
|
||||
return this.timeout<JQuery<HTMLElement>>(
|
||||
new Promise((resolve) =>
|
||||
appContext.triggerCommand("executeWithContentElement", {
|
||||
resolve,
|
||||
@@ -451,7 +395,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
async getTypeWidget() {
|
||||
return this.timeout(
|
||||
new Promise<ReactWrappedWidget | null>((resolve) =>
|
||||
new Promise<TypeWidget | null>((resolve) =>
|
||||
appContext.triggerCommand("executeWithTypeWidget", {
|
||||
resolve,
|
||||
ntxId: this.ntxId
|
||||
@@ -492,70 +436,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set metadata for this note context (e.g., table of contents, PDF pages, code outline).
|
||||
* This data can be consumed by sidebar/toolbar components.
|
||||
*
|
||||
* @param key - Unique identifier for the data type (e.g., "toc", "pdfPages", "codeOutline")
|
||||
* @param value - The data to store (will be cleared when switching notes)
|
||||
*/
|
||||
setContextData<K extends ContextDataKey>(key: K, value: NoteContextDataMap[K]): void {
|
||||
this.contextData.set(key, value);
|
||||
// Trigger event so subscribers can react
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for this note context.
|
||||
*
|
||||
* @param key - The data key to retrieve
|
||||
* @returns The stored data, or undefined if not found
|
||||
*/
|
||||
getContextData<K extends ContextDataKey>(key: K): NoteContextDataMap[K] | undefined {
|
||||
return this.contextData.get(key) as NoteContextDataMap[K] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context data exists for a given key.
|
||||
*/
|
||||
hasContextData(key: ContextDataKey): boolean {
|
||||
return this.contextData.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear specific context data.
|
||||
*/
|
||||
clearContextData(key: ContextDataKey): void {
|
||||
this.contextData.delete(key);
|
||||
this.triggerEvent("contextDataChanged", {
|
||||
noteContext: this,
|
||||
key,
|
||||
value: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function openInCurrentNoteContext(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, notePath: string, viewScope?: ViewScope) {
|
||||
const ntxId = $(evt?.target as Element)
|
||||
.closest("[data-ntx-id]")
|
||||
.attr("data-ntx-id");
|
||||
|
||||
const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) : appContext.tabManager.getActiveContext();
|
||||
|
||||
if (noteContext) {
|
||||
noteContext.setNote(notePath, { viewScope }).then(() => {
|
||||
if (noteContext !== appContext.tabManager.getActiveContext()) {
|
||||
appContext.tabManager.activateNoteContext(noteContext.ntxId);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
appContext.tabManager.openContextWithNote(notePath, { viewScope, activate: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteContext;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import froca from "../services/froca.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
import openService from "../services/open.js";
|
||||
import options from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import utils, { openInReusableSplit } from "../services/utils.js";
|
||||
import appContext, { type CommandListenerData } from "./app_context.js";
|
||||
import Component from "./component.js";
|
||||
import appContext, { type CommandData, type CommandListenerData } from "./app_context.js";
|
||||
import dateNoteService from "../services/date_notes.js";
|
||||
import treeService from "../services/tree.js";
|
||||
import openService from "../services/open.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import options from "../services/options.js";
|
||||
import froca from "../services/froca.js";
|
||||
import utils from "../services/utils.js";
|
||||
import LlmChatPanel from "../widgets/llm_chat_panel.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import noteCreateService from "../services/note_create.js";
|
||||
|
||||
export default class RootCommandExecutor extends Component {
|
||||
editReadOnlyNoteCommand() {
|
||||
@@ -66,13 +67,6 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
openNoteOnServerCommand() {
|
||||
const noteId = appContext.tabManager.getActiveContextNoteId();
|
||||
if (noteId) {
|
||||
openService.openNoteOnServer(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
enterProtectedSessionCommand() {
|
||||
protectedSessionService.enterProtectedSession();
|
||||
}
|
||||
@@ -177,8 +171,7 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
|
||||
toggleTrayCommand() {
|
||||
if (!utils.isElectron() || options.is("disableTray")) return;
|
||||
|
||||
if (!utils.isElectron()) return;
|
||||
const { BrowserWindow } = utils.dynamicRequire("@electron/remote");
|
||||
const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[];
|
||||
const isVisible = windows.every((w) => w.isVisible());
|
||||
@@ -193,19 +186,6 @@ export default class RootCommandExecutor extends Component {
|
||||
appContext.triggerEvent("zenModeChanged", { isEnabled });
|
||||
}
|
||||
|
||||
async toggleRibbonTabNoteMapCommand(data: CommandListenerData<"toggleRibbonTabNoteMap">) {
|
||||
const { isExperimentalFeatureEnabled } = await import("../services/experimental_features.js");
|
||||
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
|
||||
if (!isNewLayout) {
|
||||
this.triggerEvent("toggleRibbonTabNoteMap", data);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext?.notePath) return;
|
||||
openInReusableSplit(activeContext.notePath, "note-map");
|
||||
}
|
||||
|
||||
firstTabCommand() {
|
||||
this.#goToTab(1);
|
||||
}
|
||||
@@ -275,7 +255,7 @@ export default class RootCommandExecutor extends Component {
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Error creating AI Chat note:", e);
|
||||
toastService.showError(`Failed to create AI Chat note: ${(e as Error).message}`);
|
||||
toastService.showError("Failed to create AI Chat note: " + (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export default class TabManager extends Component {
|
||||
const activeNoteContext = this.getActiveContext();
|
||||
this.updateDocumentTitle(activeNoteContext);
|
||||
|
||||
this.triggerEvent("activeNoteChanged", {ntxId:activeNoteContext?.ntxId}); // trigger this even in on popstate event
|
||||
this.triggerEvent("activeNoteChanged", {}); // trigger this even in on popstate event
|
||||
}
|
||||
|
||||
calculateHash(): string {
|
||||
@@ -265,7 +265,6 @@ export default class TabManager extends Component {
|
||||
mainNtxId: string | null = null
|
||||
): Promise<NoteContext> {
|
||||
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
|
||||
noteContext.setEmpty();
|
||||
|
||||
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
|
||||
|
||||
@@ -647,32 +646,7 @@ export default class TabManager extends Component {
|
||||
...this.noteContexts.slice(-noteContexts.length),
|
||||
...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length)
|
||||
];
|
||||
|
||||
// Update mainNtxId if the restored pane is the main pane in the split pane
|
||||
const { oldMainNtxId, newMainNtxId } = (() => {
|
||||
if (noteContexts.length !== 1) {
|
||||
return { oldMainNtxId: undefined, newMainNtxId: undefined };
|
||||
}
|
||||
|
||||
const mainNtxId = noteContexts[0]?.mainNtxId;
|
||||
const index = this.noteContexts.findIndex(c => c.ntxId === mainNtxId);
|
||||
|
||||
// No need to update if the restored position is after mainNtxId
|
||||
if (index === -1 || lastClosedTab.position > index) {
|
||||
return { oldMainNtxId: undefined, newMainNtxId: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
oldMainNtxId: this.noteContexts[index].ntxId ?? undefined,
|
||||
newMainNtxId: noteContexts[0]?.ntxId ?? undefined
|
||||
};
|
||||
})();
|
||||
|
||||
this.triggerCommand("noteContextReorder", {
|
||||
ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null),
|
||||
oldMainNtxId,
|
||||
newMainNtxId
|
||||
});
|
||||
this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) });
|
||||
|
||||
let mainNtx = noteContexts.find((nc) => nc.isMainContext());
|
||||
if (mainNtx) {
|
||||
|
||||
@@ -1,115 +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>
|
||||
async function bootstrap() {
|
||||
await setupGlob();
|
||||
loadStylesheets();
|
||||
loadIcons();
|
||||
setBodyAttributes();
|
||||
await loadScripts();
|
||||
}
|
||||
|
||||
async function setupGlob() {
|
||||
const response = await fetch("/bootstrap");
|
||||
console.log("Service worker state", navigator.serviceWorker.controller);
|
||||
console.log("Resp", response);
|
||||
const json = await response.json();
|
||||
console.log("Bootstrap", json);
|
||||
|
||||
global = globalThis; /* fixes https://github.com/webpack/webpack/issues/10035 */
|
||||
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() {
|
||||
const assetPath = glob.assetPath;
|
||||
await import(`./${assetPath}/runtime.js`);
|
||||
await import(`./${assetPath}/desktop.js`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
</script>
|
||||
|
||||
<!-- Required for correct loading of scripts in Electron -->
|
||||
<script>
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,18 +1,18 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
|
||||
import appContext from "./components/app_context.js";
|
||||
import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import utils from "./services/utils.js";
|
||||
import noteTooltipService from "./services/note_tooltip.js";
|
||||
import bundleService from "./services/bundle.js";
|
||||
import toastService from "./services/toast.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import electronContextMenu from "./menus/electron_context_menu.js";
|
||||
import glob from "./services/glob.js";
|
||||
import { t } from "./services/i18n.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import noteTooltipService from "./services/note_tooltip.js";
|
||||
import options from "./services/options.js";
|
||||
import toastService from "./services/toast.js";
|
||||
import utils from "./services/utils.js";
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
@@ -23,7 +23,6 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
|
||||
appContext.setLayout(new DesktopLayout(widgetBundles));
|
||||
appContext.start().catch((e) => {
|
||||
toastService.showPersistent({
|
||||
id: "critical-error",
|
||||
title: t("toast.critical-error.title"),
|
||||
icon: "alert",
|
||||
message: t("toast.critical-error.message", { message: e.message })
|
||||
@@ -60,14 +59,10 @@ function initOnElectron() {
|
||||
|
||||
initDarkOrLightMode(style);
|
||||
initTransparencyEffects(style, currentWindow);
|
||||
initFullScreenDetection(currentWindow);
|
||||
|
||||
if (options.get("nativeTitleBarVisible") !== "true") {
|
||||
initTitleBarButtons(style, currentWindow);
|
||||
}
|
||||
|
||||
// Clear navigation history on frontend refresh.
|
||||
currentWindow.webContents.navigationHistory.clear();
|
||||
}
|
||||
|
||||
function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
|
||||
@@ -93,11 +88,6 @@ function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron
|
||||
}
|
||||
}
|
||||
|
||||
function initFullScreenDetection(currentWindow: Electron.BrowserWindow) {
|
||||
currentWindow.on("enter-full-screen", () => document.body.classList.add("full-screen"));
|
||||
currentWindow.on("leave-full-screen", () => document.body.classList.remove("full-screen"));
|
||||
}
|
||||
|
||||
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
|
||||
if (window.glob.platform === "win32") {
|
||||
const material = style.getPropertyValue("--background-material");
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { MIME_TYPES_DICT } from "@triliumnext/commons";
|
||||
|
||||
import server from "../services/server.js";
|
||||
import noteAttributeCache from "../services/note_attribute_cache.js";
|
||||
import ws from "../services/ws.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import cssClassManager from "../services/css_class_manager.js";
|
||||
import type { Froca } from "../services/froca-interface.js";
|
||||
import noteAttributeCache from "../services/note_attribute_cache.js";
|
||||
import protectedSessionHolder from "../services/protected_session_holder.js";
|
||||
import search from "../services/search.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import type FAttachment from "./fattachment.js";
|
||||
import type { AttributeType, default as FAttribute } from "./fattribute.js";
|
||||
import type { default as FAttribute, AttributeType } from "./fattribute.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
const LABEL = "label";
|
||||
const RELATION = "relation";
|
||||
|
||||
export const NOTE_TYPE_ICONS = {
|
||||
const NOTE_TYPE_ICONS = {
|
||||
file: "bx bx-file",
|
||||
image: "bx bx-image",
|
||||
code: "bx bx-code",
|
||||
@@ -242,7 +240,7 @@ export default class FNote {
|
||||
|
||||
const aNote = this.froca.getNoteFromCache(aNoteId);
|
||||
|
||||
if (!aNote || aNote.isArchived || aNote.isHiddenCompletely()) {
|
||||
if (aNote.isArchived || aNote.isHiddenCompletely()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -258,24 +256,8 @@ export default class FNote {
|
||||
return this.children;
|
||||
}
|
||||
|
||||
async getChildNoteIdsWithArchiveFiltering(includeArchived = false) {
|
||||
const isHiddenNote = this.noteId.startsWith("_");
|
||||
const isSearchNote = this.type === "search";
|
||||
if (!includeArchived && !isHiddenNote && !isSearchNote) {
|
||||
const unorderedIds = new Set(await search.searchForNoteIds(`note.parents.noteId="${this.noteId}" #!archived`));
|
||||
const results: string[] = [];
|
||||
for (const id of this.children) {
|
||||
if (unorderedIds.has(id)) {
|
||||
results.push(id);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
return this.children;
|
||||
}
|
||||
|
||||
async getSubtreeNoteIds(includeArchived = false) {
|
||||
const noteIds: (string | string[])[] = [];
|
||||
let noteIds: (string | string[])[] = [];
|
||||
for (const child of await this.getChildNotes()) {
|
||||
if (child.isArchived && !includeArchived) continue;
|
||||
|
||||
@@ -436,7 +418,7 @@ export default class FNote {
|
||||
return notePaths;
|
||||
}
|
||||
|
||||
getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] {
|
||||
getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] {
|
||||
const isHoistedRoot = hoistedNoteId === "root";
|
||||
|
||||
const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({
|
||||
@@ -447,23 +429,7 @@ export default class FNote {
|
||||
isHidden: path.includes("_hidden")
|
||||
}));
|
||||
|
||||
// Calculate the length of the prefix match between two arrays
|
||||
const prefixMatchLength = (path: string[], target: string[]) => {
|
||||
const diffIndex = path.findIndex((seg, i) => seg !== target[i]);
|
||||
return diffIndex === -1 ? Math.min(path.length, target.length) : diffIndex;
|
||||
};
|
||||
|
||||
notePaths.sort((a, b) => {
|
||||
if (activeNotePath) {
|
||||
const activeSegments = activeNotePath.split('/');
|
||||
const aOverlap = prefixMatchLength(a.notePath, activeSegments);
|
||||
const bOverlap = prefixMatchLength(b.notePath, activeSegments);
|
||||
// Paths with more matching prefix segments are prioritized
|
||||
// when the match count is equal, other criteria are used for sorting
|
||||
if (bOverlap !== aOverlap) {
|
||||
return bOverlap - aOverlap;
|
||||
}
|
||||
}
|
||||
if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
|
||||
return a.isInHoistedSubTree ? -1 : 1;
|
||||
} else if (a.isArchived !== b.isArchived) {
|
||||
@@ -472,8 +438,9 @@ export default class FNote {
|
||||
return a.isHidden ? 1 : -1;
|
||||
} else if (a.isSearch !== b.isSearch) {
|
||||
return a.isSearch ? 1 : -1;
|
||||
} else {
|
||||
return a.notePath.length - b.notePath.length;
|
||||
}
|
||||
return a.notePath.length - b.notePath.length;
|
||||
});
|
||||
|
||||
return notePaths;
|
||||
@@ -483,11 +450,10 @@ export default class FNote {
|
||||
* Returns the note path considered to be the "best"
|
||||
*
|
||||
* @param {string} [hoistedNoteId='root']
|
||||
* @param {string|null} [activeNotePath=null]
|
||||
* @return {string[]} array of noteIds constituting the particular note path
|
||||
*/
|
||||
getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) {
|
||||
return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath;
|
||||
getBestNotePath(hoistedNoteId = "root") {
|
||||
return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,10 +548,6 @@ export default class FNote {
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return `tn-icon ${this.#getIconInternal()}`;
|
||||
}
|
||||
|
||||
#getIconInternal() {
|
||||
const iconClassLabels = this.getLabels("iconClass");
|
||||
const workspaceIconClass = this.getWorkspaceIconClass();
|
||||
|
||||
@@ -601,13 +563,14 @@ export default class FNote {
|
||||
} else if (this.type === "text") {
|
||||
if (this.isFolder()) {
|
||||
return "bx bx-folder";
|
||||
} else {
|
||||
return "bx bx-note";
|
||||
}
|
||||
return "bx bx-note";
|
||||
} else if (this.type === "code") {
|
||||
const correspondingMimeType = MIME_TYPES_DICT.find(m => m.mime === this.mime);
|
||||
return correspondingMimeType?.icon ?? NOTE_TYPE_ICONS.code;
|
||||
} else if (this.type === "code" && this.mime.startsWith("text/x-sql")) {
|
||||
return "bx bx-data";
|
||||
} else {
|
||||
return NOTE_TYPE_ICONS[this.type];
|
||||
}
|
||||
return NOTE_TYPE_ICONS[this.type];
|
||||
}
|
||||
|
||||
getColorClass() {
|
||||
@@ -620,10 +583,10 @@ export default class FNote {
|
||||
}
|
||||
|
||||
getFilteredChildBranches() {
|
||||
const childBranches = this.getChildBranches();
|
||||
let childBranches = this.getChildBranches();
|
||||
|
||||
if (!childBranches) {
|
||||
console.error(`No children for '${this.noteId}'. This shouldn't happen.`);
|
||||
ws.logError(`No children for '${this.noteId}'. This shouldn't happen.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -809,16 +772,6 @@ export default class FNote {
|
||||
return this.getAttributeValue(LABEL, name);
|
||||
}
|
||||
|
||||
getLabelOrRelation(nameWithPrefix: string) {
|
||||
if (nameWithPrefix.startsWith("#")) {
|
||||
return this.getLabelValue(nameWithPrefix.substring(1));
|
||||
} else if (nameWithPrefix.startsWith("~")) {
|
||||
return this.getRelationValue(nameWithPrefix.substring(1));
|
||||
}
|
||||
return this.getLabelValue(nameWithPrefix);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @param name - relation name
|
||||
* @returns relation value if relation exists, null otherwise
|
||||
@@ -870,7 +823,8 @@ export default class FNote {
|
||||
return [];
|
||||
}
|
||||
|
||||
const promotedAttrs = this.getAttributeDefinitions()
|
||||
const promotedAttrs = this.getAttributes()
|
||||
.filter((attr) => attr.isDefinition())
|
||||
.filter((attr) => {
|
||||
const def = attr.getDefinition();
|
||||
|
||||
@@ -881,20 +835,15 @@ export default class FNote {
|
||||
promotedAttrs.sort((a, b) => {
|
||||
if (a.noteId === b.noteId) {
|
||||
return a.position < b.position ? -1 : 1;
|
||||
} else {
|
||||
// inherited promoted attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
|
||||
return a.noteId < b.noteId ? -1 : 1;
|
||||
}
|
||||
// inherited promoted attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
|
||||
return a.noteId < b.noteId ? -1 : 1;
|
||||
|
||||
});
|
||||
|
||||
return promotedAttrs;
|
||||
}
|
||||
|
||||
getAttributeDefinitions() {
|
||||
return this.getAttributes()
|
||||
.filter((attr) => attr.isDefinition());
|
||||
}
|
||||
|
||||
hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) {
|
||||
if (this.noteId === ancestorNoteId) {
|
||||
return true;
|
||||
@@ -996,10 +945,6 @@ export default class FNote {
|
||||
);
|
||||
}
|
||||
|
||||
isJsx() {
|
||||
return (this.type === "code" && this.mime === "text/jsx");
|
||||
}
|
||||
|
||||
/** @returns true if this note is HTML */
|
||||
isHtml() {
|
||||
return (this.type === "code" || this.type === "file" || this.type === "render") && this.mime === "text/html";
|
||||
@@ -1007,7 +952,7 @@ export default class FNote {
|
||||
|
||||
/** @returns JS script environment - either "frontend" or "backend" */
|
||||
getScriptEnv() {
|
||||
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend")) || this.isJsx()) {
|
||||
if (this.isHtml() || (this.isJavaScript() && this.mime.endsWith("env=frontend"))) {
|
||||
return "frontend";
|
||||
}
|
||||
|
||||
@@ -1029,7 +974,7 @@ export default class FNote {
|
||||
* @returns a promise that resolves when the script has been run. Additionally, for front-end notes, the promise will contain the value that is returned by the script.
|
||||
*/
|
||||
async executeScript() {
|
||||
if (!(this.isJavaScript() || this.isJsx())) {
|
||||
if (!this.isJavaScript()) {
|
||||
throw new Error(`Note ${this.noteId} is of type ${this.type} and mime ${this.mime} and thus cannot be executed`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,47 @@
|
||||
import type { AppContext } from "../components/app_context.js";
|
||||
import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import { isExperimentalFeatureEnabled } from "../services/experimental_features.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import options from "../services/options.js";
|
||||
import utils from "../services/utils.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import RightPaneToggle from "../widgets/buttons/right_pane_toggle.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
import ContentHeader from "../widgets/containers/content_header.js";
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import type { AppContext } from "../components/app_context.js";
|
||||
import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
import FloatingButtons from "../widgets/FloatingButtons.jsx";
|
||||
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
|
||||
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
|
||||
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
|
||||
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
|
||||
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
|
||||
import StatusBar from "../widgets/layout/StatusBar.jsx";
|
||||
import NoteIconWidget from "../widgets/note_icon.jsx";
|
||||
import NoteTitleWidget from "../widgets/note_title.jsx";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
|
||||
import { FixedFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.jsx";
|
||||
import NoteActions from "../widgets/ribbon/NoteActions.jsx";
|
||||
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
|
||||
import ScrollPadding from "../widgets/scroll_padding.js";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import RightPanelContainer from "../widgets/sidebar/RightPanelContainer.jsx";
|
||||
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
|
||||
import SqlResults from "../widgets/sql_result.js";
|
||||
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
|
||||
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
|
||||
import ApiLog from "../widgets/api_log.jsx";
|
||||
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
|
||||
import SharedInfo from "../widgets/shared_info.jsx";
|
||||
import NoteList from "../widgets/collections/NoteList.jsx";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
@@ -79,20 +67,17 @@ export default class DesktopLayout {
|
||||
*/
|
||||
const fullWidthTabBar = launcherPaneIsHorizontal || (isElectron && !hasNativeTitleBar && isMac);
|
||||
const customTitleBarButtons = !hasNativeTitleBar && !isMac && !isWindows;
|
||||
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
|
||||
|
||||
const rootContainer = new RootContainer(true)
|
||||
.setParent(appContext)
|
||||
.class(`${launcherPaneIsHorizontal ? "horizontal" : "vertical" }-layout`)
|
||||
.class((launcherPaneIsHorizontal ? "horizontal" : "vertical") + "-layout")
|
||||
.optChild(
|
||||
fullWidthTabBar,
|
||||
new FlexContainer("row")
|
||||
.class("tab-row-container")
|
||||
.child(new FlexContainer("row").id("tab-row-left-spacer"))
|
||||
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
|
||||
.child(<TabHistoryNavigationButtons />)
|
||||
.child(new TabRowWidget().class("full-width"))
|
||||
.optChild(isNewLayout, <RightPaneToggle />)
|
||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||
.css("height", "40px")
|
||||
.css("background-color", "var(--launcher-pane-background-color)")
|
||||
@@ -114,17 +99,7 @@ export default class DesktopLayout {
|
||||
new FlexContainer("column")
|
||||
.id("rest-pane")
|
||||
.css("flex-grow", "1")
|
||||
.optChild(!fullWidthTabBar,
|
||||
new FlexContainer("row")
|
||||
.class("tab-row-container")
|
||||
.child(<TabHistoryNavigationButtons />)
|
||||
.child(new TabRowWidget())
|
||||
.optChild(isNewLayout, <RightPaneToggle />)
|
||||
.optChild(customTitleBarButtons, <TitleBarButtons />)
|
||||
.css("height", "40px")
|
||||
.css("align-items", "center")
|
||||
)
|
||||
.optChild(isNewLayout, <FixedFormattingToolbar />)
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, <TitleBarButtons />).css("height", "40px"))
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.filling()
|
||||
@@ -138,58 +113,55 @@ export default class DesktopLayout {
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.child(new FlexContainer("row")
|
||||
.class("title-row note-split-title")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.optChild(isNewLayout, <NoteBadges />)
|
||||
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
|
||||
.optChild(!isNewLayout, <MovePaneButton direction="left" />)
|
||||
.optChild(!isNewLayout, <MovePaneButton direction="right" />)
|
||||
.optChild(!isNewLayout, <ClosePaneButton />)
|
||||
.optChild(!isNewLayout, <CreatePaneButton />)
|
||||
.optChild(isNewLayout, <NoteActions />))
|
||||
.optChild(!isNewLayout, <Ribbon />)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row")
|
||||
.css("height", "50px")
|
||||
.css("min-height", "50px")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(<NoteIconWidget />)
|
||||
.child(<NoteTitleWidget />)
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(<MovePaneButton direction="left" />)
|
||||
.child(<MovePaneButton direction="right" />)
|
||||
.child(<ClosePaneButton />)
|
||||
.child(<CreatePaneButton />)
|
||||
)
|
||||
.child(<Ribbon />)
|
||||
.child(<SharedInfo />)
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.optChild(!isNewLayout, <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.optChild(isNewLayout, <InlineTitle />)
|
||||
.optChild(isNewLayout, <NoteTitleActions />)
|
||||
.optChild(!isNewLayout, new ContentHeader()
|
||||
.child(<ReadOnlyNoteInfoBar />)
|
||||
.child(<SharedInfo />)
|
||||
)
|
||||
.optChild(!isNewLayout, <PromotedAttributes />)
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(<SqlTableSchemas />)
|
||||
.child(<NoteDetail />)
|
||||
.child(<NoteList media="screen" />)
|
||||
.child(new NoteDetailWidget())
|
||||
.child(<NoteList />)
|
||||
.child(<SearchResult />)
|
||||
.child(<SqlResults />)
|
||||
.child(<ScrollPadding />)
|
||||
)
|
||||
.child(<ApiLog />)
|
||||
.child(new FindWidget())
|
||||
.child(...this.customWidgets.get("note-detail-pane"))
|
||||
.child(
|
||||
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
|
||||
...this.customWidgets.get("note-detail-pane")
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(...this.customWidgets.get("center-pane"))
|
||||
|
||||
)
|
||||
.optChild(!isNewLayout,
|
||||
.child(
|
||||
new RightPaneContainer()
|
||||
.child(new TocWidget())
|
||||
.child(new HighlightsListWidget())
|
||||
.child(...this.customWidgets.get("right-pane"))
|
||||
)
|
||||
.optChild(isNewLayout, <RightPanelContainer widgetsByParent={this.customWidgets} />)
|
||||
)
|
||||
.optChild(!launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
|
||||
.child(<CloseZenModeButton />)
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
@@ -207,14 +179,14 @@ export default class DesktopLayout {
|
||||
launcherPane = new FlexContainer("row")
|
||||
.css("height", "53px")
|
||||
.class("horizontal")
|
||||
.child(<LauncherContainer isHorizontalLayout={true} />)
|
||||
.child(new LauncherContainer(true))
|
||||
.child(<GlobalMenu isHorizontalLayout={true} />);
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(<GlobalMenu isHorizontalLayout={false} />)
|
||||
.child(<LauncherContainer isHorizontalLayout={false} />)
|
||||
.child(new LauncherContainer(false))
|
||||
.child(<LeftPaneToggle isHorizontalLayout={false} />);
|
||||
}
|
||||
|
||||
|
||||