Compare commits

..

15 Commits

Author SHA1 Message Date
Elian Doran
41a8424b6c Revert "chore(client/dx): disable CK premium features for now"
This reverts commit a000b8a28b.
2025-08-31 12:11:48 +03:00
Elian Doran
5bc16cfe38 chore(dx/nx): remove unnecessary mcp config 2025-08-31 12:10:11 +03:00
Elian Doran
e03fa2485c chore(dx/nx): get rid of NX daemon 2025-08-31 12:09:56 +03:00
Elian Doran
00c0860b32 chore(dx/client): get SVG imports to work locally 2025-08-31 12:03:08 +03:00
Elian Doran
e3b2e92d2d chore(dx): get rid of nx.instructions.md changes 2025-08-31 12:02:34 +03:00
Elian Doran
a000b8a28b chore(client/dx): disable CK premium features for now 2025-08-31 11:58:54 +03:00
Elian Doran
ab27d36dd3 chore(client/dx): disable CK CSS imports for now 2025-08-31 11:58:29 +03:00
Elian Doran
8fb078029c chore(client/dx): highlight.js not working 2025-08-31 11:58:11 +03:00
Elian Doran
5a29c831a1 chore(client/dx): get ckeditor imports to actually work 2025-08-31 11:57:59 +03:00
Elian Doran
a2c49711bc chore(client/dx): add source to all client-side dependencies 2025-08-31 11:41:30 +03:00
Elian Doran
509fce54f9 chore(server/dx): pathless resolution via --conditions 2025-08-31 11:13:19 +03:00
Elian Doran
39ae7dda6c chore(server/dx): get server to start using path references 2025-08-31 10:58:08 +03:00
Elian Doran
0dafb86012 chore(server/dx): remove CSS import for now 2025-08-31 10:57:16 +03:00
Elian Doran
20bdae4a84 chore(server/dx): serve source directly 2025-08-31 10:49:50 +03:00
Elian Doran
a969a3c71c chore(client/dx): get client to have no dependencies 2025-08-31 10:43:55 +03:00
1729 changed files with 42099 additions and 94513 deletions

View File

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

1
.env Normal file
View File

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

View File

@@ -74,7 +74,7 @@ runs:
- name: Update build info - name: Update build info
shell: ${{ inputs.shell }} shell: ${{ inputs.shell }}
run: pnpm run chore:update-build-info run: npm run chore:update-build-info
# Critical debugging configuration # Critical debugging configuration
- name: Run electron-forge build with enhanced logging - name: Run electron-forge build with enhanced logging
@@ -86,8 +86,7 @@ runs:
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }} APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }} WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }} TRILIUM_ARTIFACT_NAME_HINT: TriliumNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
TARGET_ARCH: ${{ inputs.arch }} run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
run: pnpm run --filter desktop electron-forge:make --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
# Add DMG signing step # Add DMG signing step
- name: Sign DMG - name: Sign DMG

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. # See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
/.cache
.github/instructions/nx.instructions.md
# compiled output # compiled output
dist dist
@@ -33,11 +34,14 @@ testem.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.nx/cache
.nx/workspace-data
vite.config.*.timestamp* vite.config.*.timestamp*
vitest.config.*.timestamp* vitest.config.*.timestamp*
test-output test-output
apps/*/data* apps/*/data
apps/*/out apps/*/out
upload upload
@@ -45,7 +49,4 @@ upload
*.tsbuildinfo *.tsbuildinfo
/result /result
.svelte-kit .svelte-kit
# docs
site/

2
.nvmrc
View File

@@ -1 +1 @@
24.11.1 22.18.0

View File

@@ -5,6 +5,7 @@
"lokalise.i18n-ally", "lokalise.i18n-ally",
"ms-azuretools.vscode-docker", "ms-azuretools.vscode-docker",
"ms-playwright.playwright", "ms-playwright.playwright",
"nrwl.angular-console",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"tobermory.es6-string-html", "tobermory.es6-string-html",
"vitest.explorer", "vitest.explorer",

View File

@@ -14,7 +14,6 @@ usageMatchRegex:
# the `{key}` will be placed by a proper keypath matching regex, # the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well # you can ignore it and use your own matching rules as well
- "[^\\w\\d]t\\(['\"`]({key})['\"`]" - "[^\\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 # 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 # and works like how the i18next framework identifies the namespace scope from the

View File

@@ -5,8 +5,7 @@
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"apps/server/src/assets/translations", "apps/server/src/assets/translations",
"apps/client/src/translations", "apps/client/src/translations"
"apps/website/public/translations"
], ],
"npm.exclude": [ "npm.exclude": [
"**/dist", "**/dist",
@@ -36,5 +35,6 @@
"docs/**/*.png": true, "docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true, "apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true "apps/edit-docs/demo/**": true
} },
"nxConsole.generateAiAgentRules": true
} }

View File

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

View File

@@ -1,14 +1,3 @@
<div align="center">
<sup>Special thanks to:</sup><br />
<a href="https://go.warp.dev/Trilium" target="_blank">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png"><br />
Warp, built for coding with multiple AI agents<br />
</a>
<sup>Available for macOS, Linux and Windows</sup>
</div>
<hr />
# Trilium Notes # Trilium Notes
![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran) ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran) ![LiberaPay patrons](https://img.shields.io/liberapay/patrons/ElianDoran)
@@ -16,7 +5,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total) ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/trilium/total)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/) [![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp) [![Translation status](https://hosted.weblate.org/widget/trilium/svg-badge.svg)](https://hosted.weblate.org/engage/trilium/)
[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) [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. Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
@@ -24,27 +13,6 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a> <a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
## ⏬ Download
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) stable version, recommended for most users.
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) unstable development version, updated daily with the latest features and fixes.
## 📚 Documentation
**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
Our documentation is available in multiple formats:
- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
### Quick Links
- [Getting Started Guide](https://docs.triliumnotes.org/)
- [Installation Instructions](./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 ## 🎁 Features
* 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)) * 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))
@@ -88,6 +56,19 @@ There are no special migration steps to migrate from a zadam/Trilium instance to
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration. Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
## 📖 Documentation
We're currently in the progress of moving the documentation to in-app (hit the `F1` key within Trilium). As a result, there may be some missing parts until we've completed the migration. If you'd prefer to navigate through the documentation within GitHub, you can navigate the [User Guide](./docs/User%20Guide/User%20Guide/) documentation.
Below are some quick links for your convenience to navigate the documentation:
- [Server installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Concepts and Features - Note](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
Until we finish reorganizing the documentation, you may also want to [browse the old documentation](https://triliumnext.github.io/Docs).
## 💬 Discuss with us ## 💬 Discuss with us
Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have! Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
@@ -161,7 +142,7 @@ Download the repository, install dependencies using `pnpm` and then run the envi
git clone https://github.com/TriliumNext/Trilium.git git clone https://github.com/TriliumNext/Trilium.git
cd Trilium cd Trilium
pnpm install pnpm install
pnpm edit-docs:edit-docs pnpm nx run edit-docs:edit-docs
``` ```
### Building the Executable ### Building the Executable
@@ -170,7 +151,7 @@ Download the repository, install dependencies using `pnpm` and then build the de
git clone https://github.com/TriliumNext/Trilium.git git clone https://github.com/TriliumNext/Trilium.git
cd Trilium cd Trilium
pnpm install pnpm install
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32 pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
``` ```
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide). For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
@@ -181,34 +162,16 @@ Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blo
## 👏 Shoutouts ## 👏 Shoutouts
* [zadam](https://github.com/zadam) for the original concept and implementation of the application. * [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the application icon. * [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it.
* [nriver](https://github.com/nriver) for his work on internationalization. * [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas. * [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
Trilium would not be possible without the technologies behind it:
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
## 🤝 Support ## 🤝 Support
Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting. Support for the TriliumNext organization will be possible in the near future. For now, you can:
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/trilium/graphs/contributors))) for a full list)
Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via: - Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
- [PayPal](https://paypal.me/eliandoran)
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
## 🔑 License ## 🔑 License

View File

@@ -35,20 +35,22 @@
"chore:generate-openapi": "tsx bin/generate-openapi.js" "chore:generate-openapi": "tsx bin/generate-openapi.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.57.0", "@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.6.1", "@stylistic/eslint-plugin": "5.2.3",
"@types/express": "5.0.5", "@types/express": "5.0.3",
"@types/node": "24.10.1", "@types/node": "22.18.0",
"@types/yargs": "17.0.35", "@types/yargs": "17.0.33",
"@vitest/coverage-v8": "4.0.14", "@vitest/coverage-v8": "3.2.4",
"eslint": "9.39.1", "eslint": "9.34.0",
"eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-simple-import-sort": "12.1.1",
"esm": "3.2.25", "esm": "3.2.25",
"jsdoc": "4.0.5", "jsdoc": "4.0.4",
"lorem-ipsum": "2.0.8", "lorem-ipsum": "2.0.8",
"rcedit": "5.0.2", "rcedit": "4.0.1",
"rimraf": "6.1.2", "rimraf": "6.0.1",
"tslib": "2.8.1" "tslib": "2.8.1",
"typedoc": "0.28.11",
"typedoc-plugin-missing-exports": "4.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"appdmg": "0.6.6" "appdmg": "0.6.6"

View File

@@ -1,3 +1,4 @@
import type child_process from "child_process";
import { describe, beforeAll, afterAll } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
let etapiAuthToken: string | undefined; let etapiAuthToken: string | undefined;
@@ -11,6 +12,8 @@ type SpecDefinitionsFunc = () => void;
function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void { function describeEtapi(description: string, specDefinitions: SpecDefinitionsFunc): void {
describe(description, () => { describe(description, () => {
let appProcess: ReturnType<typeof child_process.spawn>;
beforeAll(async () => {}); beforeAll(async () => {});
afterAll(() => {}); afterAll(() => {});

15
_regroup/typedoc.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js"; import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js"; import ShortcutComponent from "./shortcut_component.js";
import { t, initLocale } from "../services/i18n.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 { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
@@ -20,6 +21,8 @@ import type LoadResults from "../services/load_results.js";
import type { Attribute } from "../services/attribute_parser.js"; import type { Attribute } from "../services/attribute_parser.js";
import type NoteTreeWidget from "../widgets/note_tree.js"; import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.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 type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js"; import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type { CKTextEditor } from "@triliumnext/ckeditor5";
@@ -30,10 +33,6 @@ import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js"; import type RootContainer from "../widgets/containers/root_container.js";
import { SqlExecuteResults } from "@triliumnext/commons"; import { SqlExecuteResults } from "@triliumnext/commons";
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
interface Layout { interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer; getRootWidget: (appContext: AppContext) => RootContainer;
@@ -117,7 +116,7 @@ export type CommandMappings = {
openedFileUpdated: CommandData & { openedFileUpdated: CommandData & {
entityType: string; entityType: string;
entityId: string; entityId: string;
lastModifiedMs?: number; lastModifiedMs: number;
filePath: string; filePath: string;
}; };
focusAndSelectTitle: CommandData & { focusAndSelectTitle: CommandData & {
@@ -200,7 +199,7 @@ export type CommandMappings = {
resetLauncher: ContextMenuCommandData; resetLauncher: ContextMenuCommandData;
executeInActiveNoteDetailWidget: CommandData & { executeInActiveNoteDetailWidget: CommandData & {
callback: (value: ReactWrappedWidget) => void; callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
}; };
executeWithTextEditor: CommandData & executeWithTextEditor: CommandData &
ExecuteCommandData<CKTextEditor> & { ExecuteCommandData<CKTextEditor> & {
@@ -212,19 +211,19 @@ export type CommandMappings = {
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}. * Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
*/ */
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>; executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
executeWithTypeWidget: CommandData & ExecuteCommandData<ReactWrappedWidget | null>; executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
addTextToActiveEditor: CommandData & { addTextToActiveEditor: CommandData & {
text: string; text: string;
}; };
/** Works only in the electron context menu. */ /** Works only in the electron context menu. */
replaceMisspelling: CommandData; replaceMisspelling: CommandData;
importMarkdownInline: CommandData;
showPasswordNotSet: CommandData; showPasswordNotSet: CommandData;
showProtectedSessionPasswordDialog: CommandData; showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string }; showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & IncludeNoteOpts; showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
showAddLinkDialog: CommandData & AddLinkOpts; showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
showPasteMarkdownDialog: CommandData & MarkdownImportOpts;
closeProtectedSessionPasswordDialog: CommandData; closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData; copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData; copyImageToClipboard: CommandData;
@@ -271,7 +270,6 @@ export type CommandMappings = {
closeThisNoteSplit: CommandData; closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean }; moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData; jumpToNote: CommandData;
openTodayNote: CommandData;
commandPalette: CommandData; commandPalette: CommandData;
// Keyboard shortcuts // Keyboard shortcuts
@@ -329,7 +327,6 @@ export type CommandMappings = {
exportAsPdf: CommandData; exportAsPdf: CommandData;
openNoteExternally: CommandData; openNoteExternally: CommandData;
openNoteCustom: CommandData; openNoteCustom: CommandData;
openNoteOnServer: CommandData;
renderActiveNote: CommandData; renderActiveNote: CommandData;
unhoist: CommandData; unhoist: CommandData;
reloadFrontendApp: CommandData; reloadFrontendApp: CommandData;
@@ -445,7 +442,6 @@ type EventMappings = {
error: string; error: string;
}; };
searchRefreshed: { ntxId?: string | null }; searchRefreshed: { ntxId?: string | null };
textEditorRefreshed: { ntxId?: string | null, editor: CKTextEditor };
hoistedNoteChanged: { hoistedNoteChanged: {
noteId: string; noteId: string;
ntxId: string | null; ntxId: string | null;
@@ -487,9 +483,14 @@ type EventMappings = {
relationMapResetPanZoom: { ntxId: string | null | undefined }; relationMapResetPanZoom: { ntxId: string | null | undefined };
relationMapResetZoomIn: { ntxId: string | null | undefined }; relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined }; relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {ntxId: string | null | undefined}; activeNoteChanged: {};
showAddLinkDialog: AddLinkOpts; showAddLinkDialog: {
showIncludeDialog: IncludeNoteOpts; textTypeWidget: EditableTextTypeWidget;
text: string;
};
showIncludeDialog: {
textTypeWidget: EditableTextTypeWidget;
};
openBulkActionsDialog: { openBulkActionsDialog: {
selectedOrActiveNoteIds: string[]; selectedOrActiveNoteIds: string[];
}; };
@@ -497,10 +498,6 @@ type EventMappings = {
noteIds: string[]; noteIds: string[];
}; };
refreshData: { ntxId: string | null | undefined }; refreshData: { ntxId: string | null | undefined };
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
}
}; };
export type EventListener<T extends EventNames> = { export type EventListener<T extends EventNames> = {
@@ -653,7 +650,7 @@ export class AppContext extends Component {
} }
getComponentByEl(el: HTMLElement) { getComponentByEl(el: HTMLElement) {
return $(el).closest("[data-component-id]").prop("component"); return $(el).closest(".component").prop("component");
} }
addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) { addBeforeUnloadListener(obj: BeforeUploadListener | (() => boolean)) {
@@ -668,10 +665,6 @@ export class AppContext extends Component {
this.beforeUnloadListeners.push(obj); this.beforeUnloadListeners.push(obj);
} }
} }
removeBeforeUnloadListener(listener: (() => boolean)) {
this.beforeUnloadListeners = this.beforeUnloadListeners.filter(l => l !== listener);
}
} }
const appContext = new AppContext(window.glob.isMainWindow); const appContext = new AppContext(window.glob.isMainWindow);

View File

@@ -159,16 +159,6 @@ export default class Entrypoints extends Component {
this.openInWindowCommand({ notePath: "", hoistedNoteId: "root" }); 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() { async runActiveNoteCommand() {
const noteContext = appContext.tabManager.getActiveContext(); const noteContext = appContext.tabManager.getActiveContext();
if (!noteContext) { if (!noteContext) {

View File

@@ -9,10 +9,10 @@ import hoistedNoteService from "../services/hoisted_note.js";
import options from "../services/options.js"; import options from "../services/options.js";
import type { ViewScope } from "../services/link.js"; import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.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 { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror"; import type CodeMirror from "@triliumnext/codemirror";
import { closeActiveDialog } from "../services/dialog.js"; import { closeActiveDialog } from "../services/dialog.js";
import { ReactWrappedWidget } from "../widgets/basic_widget.js";
export interface SetNoteOpts { export interface SetNoteOpts {
triggerSwitchEvent?: unknown; triggerSwitchEvent?: unknown;
@@ -321,20 +321,14 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false; return false;
} }
if (note.type === "search") {
return false;
}
if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) { if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
return false; return false;
} }
// Collections must always display a note list, even if no children. // Collections must always display a note list, even if no children.
if (note.type === "book") { const viewType = note.getLabelValue("viewType") ?? "grid";
const viewType = note.getLabelValue("viewType") ?? "grid"; if (!["list", "grid"].includes(viewType)) {
if (!["list", "grid"].includes(viewType)) { return true;
return true;
}
} }
if (!note.hasChildren()) { if (!note.hasChildren()) {
@@ -401,7 +395,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
async getTypeWidget() { async getTypeWidget() {
return this.timeout( return this.timeout(
new Promise<ReactWrappedWidget | null>((resolve) => new Promise<TypeWidget | null>((resolve) =>
appContext.triggerCommand("executeWithTypeWidget", { appContext.triggerCommand("executeWithTypeWidget", {
resolve, resolve,
ntxId: this.ntxId ntxId: this.ntxId
@@ -444,22 +438,4 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
} }
} }
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; export default NoteContext;

View File

@@ -7,6 +7,7 @@ import protectedSessionService from "../services/protected_session.js";
import options from "../services/options.js"; import options from "../services/options.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import LlmChatPanel from "../widgets/llm_chat_panel.js";
import toastService from "../services/toast.js"; import toastService from "../services/toast.js";
import noteCreateService from "../services/note_create.js"; import noteCreateService from "../services/note_create.js";
@@ -66,13 +67,6 @@ export default class RootCommandExecutor extends Component {
} }
} }
openNoteOnServerCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
if (noteId) {
openService.openNoteOnServer(noteId);
}
}
enterProtectedSessionCommand() { enterProtectedSessionCommand() {
protectedSessionService.enterProtectedSession(); protectedSessionService.enterProtectedSession();
} }
@@ -177,8 +171,7 @@ export default class RootCommandExecutor extends Component {
} }
toggleTrayCommand() { toggleTrayCommand() {
if (!utils.isElectron() || options.is("disableTray")) return; if (!utils.isElectron()) return;
const { BrowserWindow } = utils.dynamicRequire("@electron/remote"); const { BrowserWindow } = utils.dynamicRequire("@electron/remote");
const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[]; const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[];
const isVisible = windows.every((w) => w.isVisible()); const isVisible = windows.every((w) => w.isVisible());

View File

@@ -165,7 +165,7 @@ export default class TabManager extends Component {
const activeNoteContext = this.getActiveContext(); const activeNoteContext = this.getActiveContext();
this.updateDocumentTitle(activeNoteContext); 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 { calculateHash(): string {
@@ -265,7 +265,6 @@ export default class TabManager extends Component {
mainNtxId: string | null = null mainNtxId: string | null = null
): Promise<NoteContext> { ): Promise<NoteContext> {
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId); const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
noteContext.setEmpty();
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId); const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
@@ -434,9 +433,6 @@ export default class TabManager extends Component {
$autocompleteEl.autocomplete("close"); $autocompleteEl.autocomplete("close");
} }
// close dangling tooltips
$("body > div.tooltip").remove();
const noteContextsToRemove = noteContextToRemove.getSubContexts(); const noteContextsToRemove = noteContextToRemove.getSubContexts();
const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId); const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId);
@@ -604,18 +600,18 @@ export default class TabManager extends Component {
} }
async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) { async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId); const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
const removed = await this.removeNoteContext(ntxId); const removed = await this.removeNoteContext(ntxId);
if (removed) { if (removed) {
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
} }
} }
async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) { async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId, viewScope } = this.getNoteContextById(ntxId); const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
this.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
} }
async reopenLastTabCommand() { async reopenLastTabCommand() {
@@ -647,32 +643,7 @@ export default class TabManager extends Component {
...this.noteContexts.slice(-noteContexts.length), ...this.noteContexts.slice(-noteContexts.length),
...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length) ...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length)
]; ];
this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) });
// 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
});
let mainNtx = noteContexts.find((nc) => nc.isMainContext()); let mainNtx = noteContexts.find((nc) => nc.isMainContext());
if (mainNtx) { if (mainNtx) {

View File

@@ -23,11 +23,11 @@ export default class TouchBarComponent extends Component {
this.$widget = $("<div>"); this.$widget = $("<div>");
$(window).on("focusin", async (e) => { $(window).on("focusin", async (e) => {
const focusedEl = e.target as unknown as HTMLElement; const $target = $(e.target);
const $target = $(focusedEl);
this.$activeModal = $target.closest(".modal-dialog"); this.$activeModal = $target.closest(".modal-dialog");
this.lastFocusedComponent = appContext.getComponentByEl(focusedEl); const parentComponentEl = $target.closest(".component");
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
this.#refreshTouchBar(); this.#refreshTouchBar();
}); });
} }

View File

@@ -10,6 +10,7 @@ import { t } from "./services/i18n.js";
import options from "./services/options.js"; import options from "./services/options.js";
import type ElectronRemote from "@electron/remote"; import type ElectronRemote from "@electron/remote";
import type Electron from "electron"; import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css"; import "boxicons/css/boxicons.min.css";
import "autocomplete.js/index_jquery.js"; import "autocomplete.js/index_jquery.js";
@@ -44,10 +45,6 @@ if (utils.isElectron()) {
electronContextMenu.setupContextMenu(); electronContextMenu.setupContextMenu();
} }
if (utils.isPWA()) {
initPWATopbarColor();
}
function initOnElectron() { function initOnElectron() {
const electron: typeof Electron = utils.dynamicRequire("electron"); const electron: typeof Electron = utils.dynamicRequire("electron");
electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName)); electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName));
@@ -58,7 +55,6 @@ function initOnElectron() {
initDarkOrLightMode(style); initDarkOrLightMode(style);
initTransparencyEffects(style, currentWindow); initTransparencyEffects(style, currentWindow);
initFullScreenDetection(currentWindow);
if (options.get("nativeTitleBarVisible") !== "true") { if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow); initTitleBarButtons(style, currentWindow);
@@ -88,11 +84,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) { function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
if (window.glob.platform === "win32") { if (window.glob.platform === "win32") {
const material = style.getPropertyValue("--background-material"); const material = style.getPropertyValue("--background-material");
@@ -122,20 +113,3 @@ function initDarkOrLightMode(style: CSSStyleDeclaration) {
const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote; const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote;
nativeTheme.themeSource = themeSource; nativeTheme.themeSource = themeSource;
} }
function initPWATopbarColor() {
const tracker = $("#background-color-tracker");
if (tracker.length) {
const applyThemeColor = () => {
let meta = $("meta[name='theme-color']");
if (!meta.length) {
meta = $(`<meta name="theme-color">`).appendTo($("head"));
}
meta.attr("content", tracker.css("color"));
};
tracker.on("transitionend", applyThemeColor);
applyThemeColor();
}
}

View File

@@ -1,12 +1,12 @@
import server from "../services/server.js"; import server from "../services/server.js";
import noteAttributeCache from "../services/note_attribute_cache.js"; import noteAttributeCache from "../services/note_attribute_cache.js";
import ws from "../services/ws.js";
import protectedSessionHolder from "../services/protected_session_holder.js"; import protectedSessionHolder from "../services/protected_session_holder.js";
import cssClassManager from "../services/css_class_manager.js"; import cssClassManager from "../services/css_class_manager.js";
import type { Froca } from "../services/froca-interface.js"; import type { Froca } from "../services/froca-interface.js";
import type FAttachment from "./fattachment.js"; import type FAttachment from "./fattachment.js";
import type { default as FAttribute, AttributeType } from "./fattribute.js"; import type { default as FAttribute, AttributeType } from "./fattribute.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import search from "../services/search.js";
const LABEL = "label"; const LABEL = "label";
const RELATION = "relation"; const RELATION = "relation";
@@ -240,7 +240,7 @@ export default class FNote {
const aNote = this.froca.getNoteFromCache(aNoteId); const aNote = this.froca.getNoteFromCache(aNoteId);
if (!aNote || aNote.isArchived || aNote.isHiddenCompletely()) { if (aNote.isArchived || aNote.isHiddenCompletely()) {
return 1; return 1;
} }
@@ -256,37 +256,18 @@ export default class FNote {
return this.children; return this.children;
} }
async getChildNoteIdsWithArchiveFiltering(includeArchived = false) { async getSubtreeNoteIds() {
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;
} else {
return this.children;
}
}
async getSubtreeNoteIds(includeArchived = false) {
let noteIds: (string | string[])[] = []; let noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) { for (const child of await this.getChildNotes()) {
if (child.isArchived && !includeArchived) continue;
noteIds.push(child.noteId); noteIds.push(child.noteId);
noteIds.push(await child.getSubtreeNoteIds(includeArchived)); noteIds.push(await child.getSubtreeNoteIds());
} }
return noteIds.flat(); return noteIds.flat();
} }
async getSubtreeNotes() { async getSubtreeNotes() {
const noteIds = await this.getSubtreeNoteIds(); const noteIds = await this.getSubtreeNoteIds();
return (await this.froca.getNotes(noteIds)); return this.froca.getNotes(noteIds);
} }
async getChildNotes() { async getChildNotes() {
@@ -435,7 +416,7 @@ export default class FNote {
return notePaths; return notePaths;
} }
getSortedNotePathRecords(hoistedNoteId = "root", activeNotePath: string | null = null): NotePathRecord[] { getSortedNotePathRecords(hoistedNoteId = "root"): NotePathRecord[] {
const isHoistedRoot = hoistedNoteId === "root"; const isHoistedRoot = hoistedNoteId === "root";
const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({ const notePaths: NotePathRecord[] = this.getAllNotePaths().map((path) => ({
@@ -446,23 +427,7 @@ export default class FNote {
isHidden: path.includes("_hidden") 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) => { 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) { if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
return a.isInHoistedSubTree ? -1 : 1; return a.isInHoistedSubTree ? -1 : 1;
} else if (a.isArchived !== b.isArchived) { } else if (a.isArchived !== b.isArchived) {
@@ -483,11 +448,10 @@ export default class FNote {
* Returns the note path considered to be the "best" * Returns the note path considered to be the "best"
* *
* @param {string} [hoistedNoteId='root'] * @param {string} [hoistedNoteId='root']
* @param {string|null} [activeNotePath=null]
* @return {string[]} array of noteIds constituting the particular note path * @return {string[]} array of noteIds constituting the particular note path
*/ */
getBestNotePath(hoistedNoteId = "root", activeNotePath: string | null = null) { getBestNotePath(hoistedNoteId = "root") {
return this.getSortedNotePathRecords(hoistedNoteId, activeNotePath)[0]?.notePath; return this.getSortedNotePathRecords(hoistedNoteId)[0]?.notePath;
} }
/** /**
@@ -620,7 +584,7 @@ export default class FNote {
let childBranches = this.getChildBranches(); let childBranches = this.getChildBranches();
if (!childBranches) { 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 []; return [];
} }
@@ -806,16 +770,6 @@ export default class FNote {
return this.getAttributeValue(LABEL, name); 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));
} else {
return this.getLabelValue(nameWithPrefix);
}
}
/** /**
* @param name - relation name * @param name - relation name
* @returns relation value if relation exists, null otherwise * @returns relation value if relation exists, null otherwise
@@ -867,7 +821,8 @@ export default class FNote {
return []; return [];
} }
const promotedAttrs = this.getAttributeDefinitions() const promotedAttrs = this.getAttributes()
.filter((attr) => attr.isDefinition())
.filter((attr) => { .filter((attr) => {
const def = attr.getDefinition(); const def = attr.getDefinition();
@@ -887,11 +842,6 @@ export default class FNote {
return promotedAttrs; return promotedAttrs;
} }
getAttributeDefinitions() {
return this.getAttributes()
.filter((attr) => attr.isDefinition());
}
hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) { hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) {
if (this.noteId === ancestorNoteId) { if (this.noteId === ancestorNoteId) {
return true; return true;
@@ -955,8 +905,8 @@ export default class FNote {
return this.getBlob(); return this.getBlob();
} }
getBlob() { async getBlob() {
return this.froca.getBlob("notes", this.noteId); return await this.froca.getBlob("notes", this.noteId);
} }
toString() { toString() {

View File

@@ -1,50 +1,47 @@
import { applyModals } from "./layout_commons.js";
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import ApiLog from "../widgets/api_log.jsx";
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
import ContentHeader from "../widgets/containers/content_header.js";
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
import FindWidget from "../widgets/find.js";
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
import HighlightsListWidget from "../widgets/highlights_list.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import options from "../services/options.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
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 ScrollPadding from "../widgets/scroll_padding.js";
import SearchResult from "../widgets/search_result.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import SpacerWidget from "../widgets/spacer.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import SqlResults from "../widgets/sql_result.js";
import SqlTableSchemas from "../widgets/sql_table_schemas.js";
import TabRowWidget from "../widgets/tab_row.js"; import TabRowWidget from "../widgets/tab_row.js";
import TitleBarButtons from "../widgets/title_bar_buttons.jsx"; 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 NoteListWidget from "../widgets/note_list.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 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 type { AppContext } from "../components/app_context.js"; import type { AppContext } from "../components/app_context.js";
import type { WidgetsByParent } from "../services/bundle.js"; import type { WidgetsByParent } from "../services/bundle.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; import { applyModals } from "./layout_commons.js";
import utils from "../services/utils.js"; import Ribbon from "../widgets/ribbon/Ribbon.jsx";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import FloatingButtons from "../widgets/FloatingButtons.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx"; import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import RightPanelWidget from "../widgets/sidebar/RightPanelWidget.jsx"; import SearchResult from "../widgets/search_result.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.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 TitleBarButtons from "../widgets/title_bar_buttons.jsx";
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";
export default class DesktopLayout { export default class DesktopLayout {
@@ -126,25 +123,22 @@ export default class DesktopLayout {
.child(<NoteIconWidget />) .child(<NoteIconWidget />)
.child(<NoteTitleWidget />) .child(<NoteTitleWidget />)
.child(new SpacerWidget(0, 1)) .child(new SpacerWidget(0, 1))
.child(<MovePaneButton direction="left" />) .child(new MovePaneButton(true))
.child(<MovePaneButton direction="right" />) .child(new MovePaneButton(false))
.child(<ClosePaneButton />) .child(new ClosePaneButton())
.child(<CreatePaneButton />) .child(new CreatePaneButton())
) )
.child(<Ribbon />) .child(<Ribbon />)
.child(<SharedInfo />)
.child(new WatchedFileUpdateStatusWidget()) .child(new WatchedFileUpdateStatusWidget())
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />) .child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child( .child(
new ScrollingContainer() new ScrollingContainer()
.filling() .filling()
.child(new ContentHeader() .child(new PromotedAttributesWidget())
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />)
)
.child(<PromotedAttributes />)
.child(<SqlTableSchemas />) .child(<SqlTableSchemas />)
.child(<NoteDetail />) .child(new NoteDetailWidget())
.child(<NoteList media="screen" />) .child(new NoteListWidget(false))
.child(<SearchResult />) .child(<SearchResult />)
.child(<SqlResults />) .child(<SqlResults />)
.child(<ScrollPadding />) .child(<ScrollPadding />)

View File

@@ -22,8 +22,15 @@ import RevisionsDialog from "../widgets/dialogs/revisions.js";
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js"; import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
import InfoDialog from "../widgets/dialogs/info.js"; import InfoDialog from "../widgets/dialogs/info.js";
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js"; import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
import PopupEditorDialog from "../widgets/dialogs/popup_editor.js";
import FlexContainer from "../widgets/containers/flex_container.js";
import NoteIconWidget from "../widgets/note_icon";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import NoteListWidget from "../widgets/note_list.js";
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx"; import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx"; import NoteTitleWidget from "../widgets/note_title.jsx";
import { PopupEditorFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.js";
export function applyModals(rootContainer: RootContainer) { export function applyModals(rootContainer: RootContainer) {
rootContainer rootContainer
@@ -49,6 +56,16 @@ export function applyModals(rootContainer: RootContainer) {
.child(<ConfirmDialog />) .child(<ConfirmDialog />)
.child(<PromptDialog />) .child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />) .child(<IncorrectCpuArchDialog />)
.child(<PopupEditorDialog />) .child(new PopupEditorDialog()
.child(new FlexContainer("row")
.class("title-row")
.css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />))
.child(<PopupEditorFormattingToolbar />)
.child(new PromotedAttributesWidget())
.child(new NoteDetailWidget())
.child(new NoteListWidget(true)))
.child(<CallToActionDialog />); .child(<CallToActionDialog />);
} }

View File

@@ -1,39 +1,31 @@
import { applyModals } from "./layout_commons.js";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.js"; import NoteTitleWidget from "../widgets/note_title.js";
import ContentHeader from "../widgets/containers/content_header.js"; import NoteDetailWidget from "../widgets/note_detail.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import QuickSearchWidget from "../widgets/quick_search.js"; import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; import NoteTreeWidget from "../widgets/note_tree.js";
import RootContainer from "../widgets/containers/root_container.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js"; import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import SearchDefinitionTab from "../widgets/ribbon/SearchDefinitionTab.jsx"; import NoteListWidget from "../widgets/note_list.js";
import SearchResult from "../widgets/search_result.jsx"; import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import SharedInfoWidget from "../widgets/shared_info.js"; import SharedInfoWidget from "../widgets/shared_info.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js"; import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
import TabRowWidget from "../widgets/tab_row.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import type AppContext from "../components/app_context.js"; import type AppContext from "../components/app_context.js";
import NoteDetail from "../widgets/NoteDetail.jsx"; import TabRowWidget from "../widgets/tab_row.js";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx"; import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx"; import { applyModals } from "./layout_commons.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js"; import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
const MOBILE_CSS = ` const MOBILE_CSS = `
<style> <style>
span.keyboard-shortcut,
kbd { kbd {
display: none; display: none;
} }
@@ -47,8 +39,8 @@ kbd {
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 1.25em; font-size: 1.25em;
padding-inline-start: 0.5em; padding-left: 0.5em;
padding-inline-end: 0.5em; padding-right: 0.5em;
color: var(--main-text-color); color: var(--main-text-color);
} }
.quick-search { .quick-search {
@@ -66,7 +58,7 @@ const FANCYTREE_CSS = `
margin-top: 0px; margin-top: 0px;
overflow-y: auto; overflow-y: auto;
contain: content; contain: content;
padding-inline-start: 10px; padding-left: 10px;
} }
.fancytree-custom-icon { .fancytree-custom-icon {
@@ -75,7 +67,7 @@ const FANCYTREE_CSS = `
.fancytree-title { .fancytree-title {
font-size: 1.5em; font-size: 1.5em;
margin-inline-start: 0.6em !important; margin-left: 0.6em !important;
} }
.fancytree-node { .fancytree-node {
@@ -88,7 +80,7 @@ const FANCYTREE_CSS = `
span.fancytree-expander { span.fancytree-expander {
width: 24px !important; width: 24px !important;
margin-inline-end: 5px; margin-right: 5px;
} }
.fancytree-loading span.fancytree-expander { .fancytree-loading span.fancytree-expander {
@@ -108,7 +100,7 @@ span.fancytree-expander {
.tree-wrapper .scroll-to-active-note-button, .tree-wrapper .scroll-to-active-note-button,
.tree-wrapper .tree-settings-button { .tree-wrapper .tree-settings-button {
position: fixed; position: fixed;
margin-inline-end: 16px; margin-right: 16px;
display: none; display: none;
} }
@@ -133,46 +125,36 @@ export default class MobileLayout {
.class("d-md-flex d-lg-flex d-xl-flex col-12 col-sm-5 col-md-4 col-lg-3 col-xl-3") .class("d-md-flex d-lg-flex d-xl-flex col-12 col-sm-5 col-md-4 col-lg-3 col-xl-3")
.id("mobile-sidebar-wrapper") .id("mobile-sidebar-wrapper")
.css("max-height", "100%") .css("max-height", "100%")
.css("padding-inline-start", "0") .css("padding-left", "0")
.css("padding-inline-end", "0") .css("padding-right", "0")
.css("contain", "content") .css("contain", "content")
.child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS))) .child(new FlexContainer("column").filling().id("mobile-sidebar-wrapper").child(new QuickSearchWidget()).child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
) )
.child( .child(
new ScreenContainer("detail", "row") new ScreenContainer("detail", "column")
.id("detail-container") .id("detail-container")
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9") .class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9")
.child( .child(
new SplitNoteContainer(() => new FlexContainer("row")
new NoteWrapperWidget() .contentSized()
.child( .css("font-size", "larger")
new FlexContainer("row") .css("align-items", "center")
.contentSized() .child(<ToggleSidebarButton />)
.css("font-size", "larger") .child(<NoteTitleWidget />)
.css("align-items", "center") .child(<MobileDetailMenu />)
.child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(<PromotedAttributes />)
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfoWidget />)
)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<StandaloneRibbonAdapter component={SearchDefinitionTab} />)
.child(<SearchResult />)
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
)
) )
.child(<SharedInfoWidget />)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(new PromotedAttributesWidget())
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(new NoteDetailWidget())
.child(new NoteListWidget(false))
.child(<FilePropertiesWrapper />)
)
.child(<MobileEditorToolbar />)
) )
) )
.child( .child(
@@ -201,4 +183,4 @@ function FilePropertiesWrapper() {
{note?.type === "file" && <FilePropertiesTab note={note} />} {note?.type === "file" && <FilePropertiesTab note={note} />}
</div> </div>
); );
} }

View File

@@ -1,3 +1,5 @@
import "./stylesheets/bootstrap.scss";
// @ts-ignore - module = undefined // @ts-ignore - module = undefined
// Required for correct loading of scripts in Electron // Required for correct loading of scripts in Electron
if (typeof module === 'object') {window.module = module; module = undefined;} if (typeof module === 'object') {window.module = module; module = undefined;}

View File

@@ -1,8 +1,6 @@
import { KeyboardActionNames } from "@triliumnext/commons"; import keyboardActionService from "../services/keyboard_actions.js";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js"; import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import { h, JSX, render } from "preact";
export interface ContextMenuOptions<T> { export interface ContextMenuOptions<T> {
x: number; x: number;
@@ -15,18 +13,8 @@ export interface ContextMenuOptions<T> {
onHide?: () => void; onHide?: () => void;
} }
export interface CustomMenuItem { interface MenuSeparatorItem {
kind: "custom", title: "----";
componentFn: () => JSX.Element | null;
}
export interface MenuSeparatorItem {
kind: "separator";
}
export interface MenuHeader {
title: string;
kind: "header";
} }
export interface MenuItemBadge { export interface MenuItemBadge {
@@ -50,13 +38,12 @@ export interface MenuCommandItem<T> {
handler?: MenuHandler<T>; handler?: MenuHandler<T>;
items?: MenuItem<T>[] | null; items?: MenuItem<T>[] | null;
shortcut?: string; shortcut?: string;
keyboardShortcut?: KeyboardActionNames;
spellingSuggestion?: string; spellingSuggestion?: string;
checked?: boolean; checked?: boolean;
columns?: number; columns?: number;
} }
export type MenuItem<T> = MenuCommandItem<T> | CustomMenuItem | MenuSeparatorItem | MenuHeader; export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void; export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent; export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
@@ -155,198 +142,117 @@ class ContextMenu {
this.$widget this.$widget
.css({ .css({
display: "block", display: "block",
top, top: top,
left left: left
}) })
.addClass("show"); .addClass("show");
} }
addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[], multicolumn = false) { addItems($parent: JQuery<HTMLElement>, items: MenuItem<any>[]) {
let $group = $parent; // The current group or parent element to which items are being appended for (const item of items) {
let shouldStartNewGroup = false; // If true, the next item will start a new group
let shouldResetGroup = false; // If true, the next item will be the last one from the group
let prevItemKind: string = "";
for (let index = 0; index < items.length; index++) {
const item = items[index];
const itemKind = ("kind" in item) ? item.kind : "";
if (!item) { if (!item) {
continue; continue;
} }
// If the current item is a header, start a new group. This group will contain the if (item.title === "----") {
// header and the next item that follows the header. $parent.append($("<div>").addClass("dropdown-divider"));
if (itemKind === "header") {
if (multicolumn && !shouldResetGroup) {
shouldStartNewGroup = true;
}
}
// If the next item is a separator, start a new group. This group will contain the
// current item, the separator, and the next item after the separator.
const nextItem = (index < items.length - 1) ? items[index + 1] : null;
if (multicolumn && nextItem && "kind" in nextItem && nextItem.kind === "separator") {
if (!shouldResetGroup) {
shouldStartNewGroup = true;
} else {
shouldResetGroup = true; // Continue the current group
}
}
// Create a new group to avoid column breaks before and after the seaparator / header.
// This is a workaround for Firefox not supporting break-before / break-after: avoid
// for columns.
if (shouldStartNewGroup) {
$group = $("<div class='dropdown-no-break'>");
$parent.append($group);
shouldStartNewGroup = false;
}
if (itemKind === "separator") {
if (prevItemKind === "separator") {
// Skip consecutive separators
continue;
}
$group.append($("<div>").addClass("dropdown-divider"));
shouldResetGroup = true; // End the group after the next item
} else if (itemKind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text((item as MenuHeader).title));
shouldResetGroup = true;
} else { } else {
if (itemKind === "custom") { const $icon = $("<span>");
// Custom menu item
$group.append(this.createCustomMenuItem(item as CustomMenuItem)); if ("uiIcon" in item || "checked" in item) {
} else { const icon = (item.checked ? "bx bx-check" : item.uiIcon);
// Standard menu item if (icon) {
$group.append(this.createMenuItem(item as MenuCommandItem<any>)); $icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
} }
// After adding a menu item, if the previous item was a separator or header, const $link = $("<span>")
// reset the group so that the next item will be appended directly to the parent. .append($icon)
if (shouldResetGroup) { .append(" &nbsp; ") // some space between icon and text
$group = $parent; .append(item.title);
shouldResetGroup = false;
};
}
prevItemKind = itemKind; if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
} if (badge.className) {
} badgeElement.addClass(badge.className);
}
private createCustomMenuItem(item: CustomMenuItem) { $link.append(badgeElement);
const element = document.createElement("li"); }
element.classList.add("dropdown-custom-item"); }
element.onclick = () => this.hide();
render(h(item.componentFn, {}), element);
return element;
}
private createMenuItem(item: MenuCommandItem<any>) { if ("shortcut" in item && item.shortcut) {
const $icon = $("<span>"); $link.append($("<kbd>").text(item.shortcut));
}
if ("uiIcon" in item || "checked" in item) { const $item = $("<li>")
const icon = (item.checked ? "bx bx-check" : item.uiIcon); .addClass("dropdown-item")
if (icon) { .append($link)
$icon.addClass(icon); .on("contextmenu", (e) => false)
} else { // important to use mousedown instead of click since the former does not change focus
$icon.append("&nbsp;"); // (especially important for focused text for spell check)
.on("mousedown", (e) => {
e.stopPropagation();
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
$item.on("mouseup", (e) => {
// Prevent submenu from failing to expand on mobile
if (!this.isMobile || !("items" in item && item.items)) {
e.stopPropagation();
// Hide the content menu on mouse up to prevent the mouse event from propagating to the elements below.
this.hide();
return false;
}
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$link.addClass("dropdown-toggle");
const $subMenu = $("<ul>").addClass("dropdown-menu");
if (!this.isMobile && item.columns) {
$subMenu.css("column-count", item.columns);
}
this.addItems($subMenu, item.items);
$item.append($subMenu);
}
$parent.append($item);
} }
} }
const $link = $("<span>")
.append($icon)
.append(" &nbsp; ") // some space between icon and text
.append(item.title);
if ("badges" in item && item.badges) {
for (let badge of item.badges) {
const badgeElement = $(`<span class="badge">`).text(badge.title);
if (badge.className) {
badgeElement.addClass(badge.className);
}
$link.append(badgeElement);
}
}
if ("keyboardShortcut" in item && item.keyboardShortcut) {
const shortcuts = getActionSync(item.keyboardShortcut).effectiveShortcuts;
if (shortcuts) {
const allShortcuts: string[] = [];
for (const effectiveShortcut of shortcuts) {
allShortcuts.push(effectiveShortcut.split("+")
.map(key => `<kbd>${key}</kbd>`)
.join("+"));
}
if (allShortcuts.length) {
const container = $("<span>").addClass("keyboard-shortcut");
container.append($(allShortcuts.join(",")));
$link.append(container);
}
}
} else if ("shortcut" in item && item.shortcut) {
$link.append($("<kbd>").text(item.shortcut));
}
const $item = $("<li>")
.addClass("dropdown-item")
.append($link)
.on("contextmenu", (e) => false)
// important to use mousedown instead of click since the former does not change focus
// (especially important for focused text for spell check)
.on("mousedown", (e) => {
if (e.which !== 1) {
// only left click triggers menu items
return false;
}
if (this.isMobile && "items" in item && item.items) {
const $item = $(e.target).closest(".dropdown-item");
$item.toggleClass("submenu-open");
$item.find("ul.dropdown-menu").toggleClass("show");
return false;
}
// Prevent submenu from failing to expand on mobile
if (!("items" in item && item.items)) {
this.hide();
}
if ("handler" in item && item.handler) {
item.handler(item, e);
}
this.options?.selectMenuItemHandler(item, e);
// it's important to stop the propagation especially for sub-menus, otherwise the event
// might be handled again by top-level menu
return false;
});
if ("enabled" in item && item.enabled !== undefined && !item.enabled) {
$item.addClass("disabled");
}
if ("items" in item && item.items) {
$item.addClass("dropdown-submenu");
$link.addClass("dropdown-toggle");
const $subMenu = $("<ul>").addClass("dropdown-menu");
const hasColumns = !!item.columns && item.columns > 1;
if (!this.isMobile && hasColumns) {
$subMenu.css("column-count", item.columns!);
}
this.addItems($subMenu, item.items, hasColumns);
$item.append($subMenu);
}
return $item;
} }
async hide() { async hide() {

View File

@@ -1,21 +0,0 @@
import { t } from "../services/i18n"
import attributes from "../services/attributes"
import FNote from "../entities/fnote"
export function getArchiveMenuItem(note: FNote) {
if (!note.isArchived) {
return {
title: t("board_view.archive-note"),
uiIcon: "bx bx-archive",
handler: () => attributes.addLabel(note.noteId, "archived")
}
} else {
return {
title: t("board_view.unarchive-note"),
uiIcon: "bx bx-archive-out",
handler: async () => {
attributes.removeOwnedLabelByName(note, "archived")
}
}
}
}

View File

@@ -1,86 +0,0 @@
:root {
--note-color-picker-clear-color-cell-background: var(--primary-button-background-color);
--note-color-picker-clear-color-cell-color: var(--main-background-color);
--note-color-picker-clear-color-cell-selection-outline-color: var(--primary-button-border-color);
}
.note-color-picker {
display: flex;
gap: 8px;
justify-content: space-between;
}
.note-color-picker .color-cell {
--color-picker-cell-size: 14px;
width: var(--color-picker-cell-size);
height: var(--color-picker-cell-size);
border-radius: 4px;
background-color: var(--color);
}
.note-color-picker .color-cell:not(.selected):hover {
transform: scale(1.2);
}
.note-color-picker .color-cell.disabled-color-cell {
cursor: not-allowed;
}
.note-color-picker .color-cell.selected {
outline: 2px solid var(--outline-color, var(--color));
outline-offset: 2px;
}
/*
* RESET COLOR CELL
*/
.note-color-picker .color-cell-reset {
--color: var(--note-color-picker-clear-color-cell-background);
--outline-color: var(--note-color-picker-clear-color-cell-selection-outline-color);
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.note-color-picker .color-cell-reset svg {
width: var(--color-picker-cell-size);
height: var(--color-picker-cell-size);
fill: var(--note-color-picker-clear-color-cell-color);
}
/*
* CUSTOM COLOR CELL
*/
.note-color-picker .custom-color-cell::before {
position: absolute;
content: "\ed35";
display: flex;
top: 0;
left: 0;
right: 0;
bottom: 0;
font-size: calc(var(--color-picker-cell-size) * 1.3);
justify-content: center;
align-items: center;
font-family: boxicons;
font-size: 16px;
color: var(--foreground);
}
.note-color-picker .custom-color-cell {
position: relative;
display: flex;
justify-content: center;
overflow: hidden;
}
.note-color-picker .custom-color-cell.custom-color-cell-empty {
background-image: url(./custom-color.png);
background-size: cover;
--foreground: transparent;
}

View File

@@ -1,204 +0,0 @@
import "./NoteColorPicker.css";
import { t } from "../../services/i18n";
import { useCallback, useEffect, useRef, useState} from "preact/hooks";
import {ComponentChildren} from "preact";
import attributes from "../../services/attributes";
import clsx from "clsx";
import Color, { ColorInstance } from "color";
import Debouncer from "../../utils/debouncer";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { isMobile } from "../../services/utils";
const COLOR_PALETTE = [
"#e64d4d", "#e6994d", "#e5e64d", "#99e64d", "#4de64d", "#4de699",
"#4de5e6", "#4d99e6", "#4d4de6", "#994de6", "#e64db3"
];
export interface NoteColorPickerProps {
/** The target Note instance or its ID string. */
note: FNote | string | null;
}
export default function NoteColorPicker(props: NoteColorPickerProps) {
if (!props.note) return null;
const [note, setNote] = useState<FNote | null>(null);
const [currentColor, setCurrentColor] = useState<string | null>(null);
const [isCustomColor, setIsCustomColor] = useState<boolean>(false);
useEffect(() => {
const retrieveNote = async (noteId: string) => {
const noteInstance = await froca.getNote(noteId, true);
if (noteInstance) {
setNote(noteInstance);
}
}
if (typeof props.note === "string") {
retrieveNote(props.note); // Get the note from the given ID string
} else {
setNote(props.note);
}
}, []);
useEffect(() => {
const colorLabel = note?.getLabel("color")?.value ?? null;
if (colorLabel) {
let color = tryParseColor(colorLabel);
if (color) {
setCurrentColor(color.hex().toLowerCase());
}
}
}, [note]);
useEffect(() => {
setIsCustomColor(currentColor !== null && COLOR_PALETTE.indexOf(currentColor) === -1);
}, [currentColor])
const onColorCellClicked = useCallback((color: string | null) => {
if (note) {
if (color !== null) {
attributes.setLabel(note.noteId, "color", color);
} else {
attributes.removeOwnedLabelByName(note, "color");
}
setCurrentColor(color);
}
}, [note, currentColor]);
return <div className="note-color-picker">
<ColorCell className="color-cell-reset"
tooltip={t("note-color.clear-color")}
color={null}
isSelected={(currentColor === null)}
isDisabled={(note === null)}
onSelect={onColorCellClicked}>
{/* https://pictogrammers.com/library/mdi/icon/close/ */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</svg>
</ColorCell>
{COLOR_PALETTE.map((color) => (
<ColorCell key={color}
tooltip={t("note-color.set-color")}
color={color}
isSelected={(color === currentColor)}
isDisabled={(note === null)}
onSelect={onColorCellClicked} />
))}
<CustomColorCell tooltip={t("note-color.set-custom-color")}
color={currentColor}
isSelected={isCustomColor}
isDisabled={(note === null)}
onSelect={onColorCellClicked} />
</div>
}
interface ColorCellProps {
children?: ComponentChildren,
className?: string,
tooltip?: string,
color: string | null,
isSelected: boolean,
isDisabled?: boolean,
onSelect?: (color: string | null) => void
}
function ColorCell(props: ColorCellProps) {
return <div className={clsx(props.className, {
"color-cell": true,
"selected": props.isSelected,
"disabled-color-cell": props.isDisabled
})}
style={`${(props.color !== null) ? `--color: ${props.color}` : ""}`}
title={props.tooltip}
onClick={() => props.onSelect?.(props.color)}>
{props.children}
</div>;
}
function CustomColorCell(props: ColorCellProps) {
const [pickedColor, setPickedColor] = useState<string | null>(null);
const colorInput = useRef<HTMLInputElement>(null);
const colorInputDebouncer = useRef<Debouncer<string | null> | null>(null);
const callbackRef = useRef(props.onSelect);
useEffect(() => {
colorInputDebouncer.current = new Debouncer(250, (color) => {
callbackRef.current?.(color);
setPickedColor(color);
});
return () => {
colorInputDebouncer.current?.destroy();
}
}, []);
useEffect(() => {
if (props.isSelected && pickedColor === null) {
setPickedColor(props.color);
}
}, [props.isSelected])
useEffect(() => {
callbackRef.current = props.onSelect;
}, [props.onSelect]);
const onSelect = useCallback(() => {
if (pickedColor !== null) {
callbackRef.current?.(pickedColor);
}
colorInput.current?.click();
}, [pickedColor]);
return <div style={`--foreground: ${getForegroundColor(props.color)};`}
onClick={isMobile() ? (e) => {
// The color picker dropdown will close on some browser if the parent context menu is
// dismissed, so stop the click propagation to prevent dismissing the menu.
e.stopPropagation();
} : undefined}>
<ColorCell {...props}
color={pickedColor}
className={clsx("custom-color-cell", {
"custom-color-cell-empty": (pickedColor === null)
})}
onSelect={onSelect}>
<input ref={colorInput}
type="color"
value={pickedColor ?? props.color ?? "#40bfbf"}
onChange={() => {colorInputDebouncer.current?.updateValue(colorInput.current?.value ?? null)}}
style="width: 0; height: 0; opacity: 0" />
</ColorCell>
</div>
}
function getForegroundColor(backgroundColor: string | null) {
if (backgroundColor === null) return "inherit";
const colorHsl = tryParseColor(backgroundColor)?.hsl();
if (colorHsl) {
let l = colorHsl.lightness();
return colorHsl.saturationl(0).lightness(l >= 50 ? 0 : 100).hex();
} else {
return "inherit";
}
}
function tryParseColor(colorStr: string): ColorInstance | null {
try {
return new Color(colorStr);
} catch(ex) {
console.error(ex);
}
return null;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -4,7 +4,7 @@ import zoomService from "../components/zoom.js";
import contextMenu, { type MenuItem } from "./context_menu.js"; import contextMenu, { type MenuItem } from "./context_menu.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import type { BrowserWindow } from "electron"; import type { BrowserWindow } from "electron";
import type { CommandNames, AppContext } from "../components/app_context.js"; import type { CommandNames } from "../components/app_context.js";
function setupContextMenu() { function setupContextMenu() {
const electron = utils.dynamicRequire("electron"); const electron = utils.dynamicRequire("electron");
@@ -13,8 +13,6 @@ function setupContextMenu() {
// FIXME: Remove typecast once Electron is properly integrated. // FIXME: Remove typecast once Electron is properly integrated.
const { webContents } = remote.getCurrentWindow() as BrowserWindow; const { webContents } = remote.getCurrentWindow() as BrowserWindow;
let appContext: AppContext;
webContents.on("context-menu", (event, params) => { webContents.on("context-menu", (event, params) => {
const { editFlags } = params; const { editFlags } = params;
const hasText = params.selectionText.trim().length > 0; const hasText = params.selectionText.trim().length > 0;
@@ -39,7 +37,7 @@ function setupContextMenu() {
handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord) handler: () => webContents.session.addWordToSpellCheckerDictionary(params.misspelledWord)
}); });
items.push({ kind: "separator" }); items.push({ title: `----` });
} }
if (params.isEditable) { if (params.isEditable) {
@@ -114,27 +112,13 @@ function setupContextMenu() {
// Replace the placeholder with the real search keyword. // Replace the placeholder with the real search keyword.
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText)); let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
items.push({ kind: "separator" }); items.push({ title: "----" });
items.push({ items.push({
title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }), title: t("electron_context_menu.search_online", { term: shortenedSelection, searchEngine: searchEngineName }),
uiIcon: "bx bx-search-alt", uiIcon: "bx bx-search-alt",
handler: () => electron.shell.openExternal(searchUrl) handler: () => electron.shell.openExternal(searchUrl)
}); });
items.push({
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
uiIcon: "bx bx-search",
handler: async () => {
if (!appContext) {
appContext = (await import("../components/app_context.js")).default;
}
await appContext.triggerCommand("searchNotes", {
searchString: params.selectionText
});
}
});
} }
if (items.length === 0) { if (items.length === 0) {

View File

@@ -45,16 +45,16 @@ export default class LauncherContextMenu implements SelectMenuItemEventListener<
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-script-launcher"), command: "addScriptLauncher", uiIcon: "bx bx-code-curly" } : null, isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-script-launcher"), command: "addScriptLauncher", uiIcon: "bx bx-code-curly" } : null,
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-custom-widget"), command: "addWidgetLauncher", uiIcon: "bx bx-customize" } : null, isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-custom-widget"), command: "addWidgetLauncher", uiIcon: "bx bx-customize" } : null,
isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-spacer"), command: "addSpacerLauncher", uiIcon: "bx bx-dots-horizontal" } : null, isVisibleRoot || isAvailableRoot ? { title: t("launcher_context_menu.add-spacer"), command: "addSpacerLauncher", uiIcon: "bx bx-dots-horizontal" } : null,
isVisibleRoot || isAvailableRoot ? { kind: "separator" } : null, isVisibleRoot || isAvailableRoot ? { title: "----" } : null,
isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null, isAvailableItem ? { title: t("launcher_context_menu.move-to-visible-launchers"), command: "moveLauncherToVisible", uiIcon: "bx bx-show", enabled: true } : null,
isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null, isVisibleItem ? { title: t("launcher_context_menu.move-to-available-launchers"), command: "moveLauncherToAvailable", uiIcon: "bx bx-hide", enabled: true } : null,
isVisibleItem || isAvailableItem ? { kind: "separator" } : null, isVisibleItem || isAvailableItem ? { title: "----" } : null,
{ title: `${t("launcher_context_menu.duplicate-launcher")}`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem }, { title: `${t("launcher_context_menu.duplicate-launcher")}`, command: "duplicateSubtree", uiIcon: "bx bx-outline", enabled: isItem },
{ title: `${t("launcher_context_menu.delete")}`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted }, { title: `${t("launcher_context_menu.delete")}`, command: "deleteNotes", uiIcon: "bx bx-trash destructive-action-icon", enabled: canBeDeleted },
{ kind: "separator" }, { title: "----" },
{ title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset } { title: t("launcher_context_menu.reset"), command: "resetLauncher", uiIcon: "bx bx-reset destructive-action-icon", enabled: canBeReset }
]; ];

View File

@@ -2,32 +2,26 @@ import { t } from "../services/i18n.js";
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
import appContext, { type CommandNames } from "../components/app_context.js"; import appContext, { type CommandNames } from "../components/app_context.js";
import type { ViewScope } from "../services/link.js"; import type { ViewScope } from "../services/link.js";
import utils, { isMobile } from "../services/utils.js";
import { getClosestNtxId } from "../widgets/widget_utils.js";
import type { LeafletMouseEvent } from "leaflet";
function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) { function openContextMenu(notePath: string, e: ContextMenuEvent, viewScope: ViewScope = {}, hoistedNoteId: string | null = null) {
contextMenu.show({ contextMenu.show({
x: e.pageX, x: e.pageX,
y: e.pageY, y: e.pageY,
items: getItems(e), items: getItems(),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, e, notePath, viewScope, hoistedNoteId) selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId)
}); });
} }
function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem<CommandNames>[] { function getItems(): MenuItem<CommandNames>[] {
const ntxId = getNtxId(e);
const isMobileSplitOpen = isMobile() && appContext.tabManager.getNoteContextById(ntxId).getMainContext().getSubContexts().length > 1;
return [ return [
{ title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" }, { title: t("link_context_menu.open_note_in_new_tab"), command: "openNoteInNewTab", uiIcon: "bx bx-link-external" },
{ title: !isMobileSplitOpen ? t("link_context_menu.open_note_in_new_split") : t("link_context_menu.open_note_in_other_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" }, { title: t("link_context_menu.open_note_in_new_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" },
{ title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" }, { title: t("link_context_menu.open_note_in_new_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" },
{ title: t("link_context_menu.open_note_in_popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit" } { title: t("link_context_menu.open_note_in_popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit" }
]; ];
} }
function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEvent | LeafletMouseEvent, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) { function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
if (!hoistedNoteId) { if (!hoistedNoteId) {
hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null; hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null;
} }
@@ -35,8 +29,15 @@ function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEv
if (command === "openNoteInNewTab") { if (command === "openNoteInNewTab") {
appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope }); appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope });
} else if (command === "openNoteInNewSplit") { } else if (command === "openNoteInNewSplit") {
const ntxId = getNtxId(e); const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
if (!ntxId) return;
if (!subContexts) {
logError("subContexts is null");
return;
}
const { ntxId } = subContexts[subContexts.length - 1];
appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope }); appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope });
} else if (command === "openNoteInNewWindow") { } else if (command === "openNoteInNewWindow") {
appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope }); appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
@@ -45,18 +46,6 @@ function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEv
} }
} }
function getNtxId(e: ContextMenuEvent | LeafletMouseEvent) {
if (utils.isDesktop()) {
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
if (!subContexts) return null;
return subContexts[subContexts.length - 1].ntxId;
} else if (e.target instanceof HTMLElement) {
return getClosestNtxId(e.target);
} else {
return null;
}
}
export default { export default {
getItems, getItems,
handleLinkContextMenuItem, handleLinkContextMenuItem,

View File

@@ -1,4 +1,3 @@
import NoteColorPicker from "./custom-items/NoteColorPicker.jsx";
import treeService from "../services/tree.js"; import treeService from "../services/tree.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import clipboard from "../services/clipboard.js"; import clipboard from "../services/clipboard.js";
@@ -14,8 +13,6 @@ import type NoteTreeWidget from "../widgets/note_tree.js";
import type FAttachment from "../entities/fattachment.js"; import type FAttachment from "../entities/fattachment.js";
import type { SelectMenuItemEventListener } from "../components/events.js"; import type { SelectMenuItemEventListener } from "../components/events.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import attributes from "../services/attributes.js";
import { executeBulkActions } from "../services/bulk_action.js";
// TODO: Deduplicate once client/server is well split. // TODO: Deduplicate once client/server is well split.
interface ConvertToAttachmentResponse { interface ConvertToAttachmentResponse {
@@ -64,11 +61,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
// the only exception is when the only selected note is the one that was right-clicked, then // the only exception is when the only selected note is the one that was right-clicked, then
// it's clear what the user meant to do. // it's clear what the user meant to do.
const selNodes = this.treeWidget.getSelectedNodes(); const selNodes = this.treeWidget.getSelectedNodes();
const selectedNotes = await froca.getNotes(selNodes.map(node => node.data.noteId));
if (note && !selectedNotes.includes(note)) selectedNotes.push(note);
const isArchived = selectedNotes.every(note => note.isArchived);
const canToggleArchived = !selectedNotes.some(note => note.isArchived !== isArchived);
const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node); const noSelectedNotes = selNodes.length === 0 || (selNodes.length === 1 && selNodes[0] === this.node);
const notSearch = note?.type !== "search"; const notSearch = note?.type !== "search";
@@ -77,29 +69,27 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch; const insertNoteAfterEnabled = isNotRoot && !isHoisted && parentNotSearch;
const items: (MenuItem<TreeCommandNames> | null)[] = [ const items: (MenuItem<TreeCommandNames> | null)[] = [
{ title: t("tree-context-menu.open-in-a-new-tab"), command: "openInTab", shortcut: "Ctrl+Click", uiIcon: "bx bx-link-external", enabled: noSelectedNotes }, { title: `${t("tree-context-menu.open-in-a-new-tab")}`, command: "openInTab", uiIcon: "bx bx-link-external", enabled: noSelectedNotes },
{ title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes }, { title: t("tree-context-menu.open-in-a-new-split"), command: "openNoteInSplit", uiIcon: "bx bx-dock-right", enabled: noSelectedNotes },
{ title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes }, { title: t("tree-context-menu.open-in-popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit", enabled: noSelectedNotes },
isHoisted isHoisted
? null ? null
: { : {
title: `${t("tree-context-menu.hoist-note")}`, title: `${t("tree-context-menu.hoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`,
command: "toggleNoteHoisting", command: "toggleNoteHoisting",
keyboardShortcut: "toggleNoteHoisting",
uiIcon: "bx bxs-chevrons-up", uiIcon: "bx bxs-chevrons-up",
enabled: noSelectedNotes && notSearch enabled: noSelectedNotes && notSearch
}, },
!isHoisted || !isNotRoot !isHoisted || !isNotRoot
? null ? null
: { title: t("tree-context-menu.unhoist-note"), command: "toggleNoteHoisting", keyboardShortcut: "toggleNoteHoisting", uiIcon: "bx bx-door-open" }, : { title: `${t("tree-context-menu.unhoist-note")} <kbd data-command="toggleNoteHoisting"></kbd>`, command: "toggleNoteHoisting", uiIcon: "bx bx-door-open" },
{ kind: "separator" }, { title: "----" },
{ {
title: t("tree-context-menu.insert-note-after"), title: `${t("tree-context-menu.insert-note-after")}<kbd data-command="createNoteAfter"></kbd>`,
command: "insertNoteAfter", command: "insertNoteAfter",
keyboardShortcut: "createNoteAfter",
uiIcon: "bx bx-plus", uiIcon: "bx bx-plus",
items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null, items: insertNoteAfterEnabled ? await noteTypesService.getNoteTypeItems("insertNoteAfter") : null,
enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp, enabled: insertNoteAfterEnabled && noSelectedNotes && notOptionsOrHelp,
@@ -107,22 +97,21 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
}, },
{ {
title: t("tree-context-menu.insert-child-note"), title: `${t("tree-context-menu.insert-child-note")}<kbd data-command="createNoteInto"></kbd>`,
command: "insertChildNote", command: "insertChildNote",
keyboardShortcut: "createNoteInto",
uiIcon: "bx bx-plus", uiIcon: "bx bx-plus",
items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null, items: notSearch ? await noteTypesService.getNoteTypeItems("insertChildNote") : null,
enabled: notSearch && noSelectedNotes && notOptionsOrHelp, enabled: notSearch && noSelectedNotes && notOptionsOrHelp,
columns: 2 columns: 2
}, },
{ kind: "separator" }, { title: "----" },
{ title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes }, { title: t("tree-context-menu.protect-subtree"), command: "protectSubtree", uiIcon: "bx bx-check-shield", enabled: noSelectedNotes },
{ title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes }, { title: t("tree-context-menu.unprotect-subtree"), command: "unprotectSubtree", uiIcon: "bx bx-shield", enabled: noSelectedNotes },
{ kind: "separator" }, { title: "----" },
{ {
title: t("tree-context-menu.advanced"), title: t("tree-context-menu.advanced"),
@@ -131,58 +120,48 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
items: [ items: [
{ title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true }, { title: t("tree-context-menu.apply-bulk-actions"), command: "openBulkActionsDialog", uiIcon: "bx bx-list-plus", enabled: true },
{ kind: "separator" }, { title: "----" },
{ {
title: t("tree-context-menu.edit-branch-prefix"), title: `${t("tree-context-menu.edit-branch-prefix")} <kbd data-command="editBranchPrefix"></kbd>`,
command: "editBranchPrefix", command: "editBranchPrefix",
keyboardShortcut: "editBranchPrefix",
uiIcon: "bx bx-rename", uiIcon: "bx bx-rename",
enabled: isNotRoot && parentNotSearch && notOptionsOrHelp enabled: isNotRoot && parentNotSearch && noSelectedNotes && notOptionsOrHelp
},
{
title:
t("tree-context-menu.convert-to-attachment"),
command: "convertNoteToAttachment",
uiIcon: "bx bx-paperclip",
enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment())
}, },
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
{ kind: "separator" }, { title: "----" },
{ title: t("tree-context-menu.expand-subtree"), command: "expandSubtree", keyboardShortcut: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes }, { title: `${t("tree-context-menu.expand-subtree")} <kbd data-command="expandSubtree"></kbd>`, command: "expandSubtree", uiIcon: "bx bx-expand", enabled: noSelectedNotes },
{ title: t("tree-context-menu.collapse-subtree"), command: "collapseSubtree", keyboardShortcut: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes }, { title: `${t("tree-context-menu.collapse-subtree")} <kbd data-command="collapseSubtree"></kbd>`, command: "collapseSubtree", uiIcon: "bx bx-collapse", enabled: noSelectedNotes },
{ {
title: t("tree-context-menu.sort-by"), title: `${t("tree-context-menu.sort-by")} <kbd data-command="sortChildNotes"></kbd>`,
command: "sortChildNotes", command: "sortChildNotes",
keyboardShortcut: "sortChildNotes",
uiIcon: "bx bx-sort-down", uiIcon: "bx bx-sort-down",
enabled: noSelectedNotes && notSearch enabled: noSelectedNotes && notSearch
}, },
{ kind: "separator" }, { title: "----" },
{ title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true }, { title: t("tree-context-menu.copy-note-path-to-clipboard"), command: "copyNotePathToClipboard", uiIcon: "bx bx-directions", enabled: true },
{ title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp } { title: t("tree-context-menu.recent-changes-in-subtree"), command: "recentChangesInSubtree", uiIcon: "bx bx-history", enabled: noSelectedNotes && notOptionsOrHelp }
] ]
}, },
{ kind: "separator" }, { title: "----" },
{ {
title: t("tree-context-menu.cut"), title: `${t("tree-context-menu.cut")} <kbd data-command="cutNotesToClipboard"></kbd>`,
command: "cutNotesToClipboard", command: "cutNotesToClipboard",
keyboardShortcut: "cutNotesToClipboard",
uiIcon: "bx bx-cut", uiIcon: "bx bx-cut",
enabled: isNotRoot && !isHoisted && parentNotSearch enabled: isNotRoot && !isHoisted && parentNotSearch
}, },
{ title: t("tree-context-menu.copy-clone"), command: "copyNotesToClipboard", keyboardShortcut: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted }, { title: `${t("tree-context-menu.copy-clone")} <kbd data-command="copyNotesToClipboard"></kbd>`, command: "copyNotesToClipboard", uiIcon: "bx bx-copy", enabled: isNotRoot && !isHoisted },
{ {
title: t("tree-context-menu.paste-into"), title: `${t("tree-context-menu.paste-into")} <kbd data-command="pasteNotesFromClipboard"></kbd>`,
command: "pasteNotesFromClipboard", command: "pasteNotesFromClipboard",
keyboardShortcut: "pasteNotesFromClipboard",
uiIcon: "bx bx-paste", uiIcon: "bx bx-paste",
enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes enabled: !clipboard.isClipboardEmpty() && notSearch && noSelectedNotes
}, },
@@ -195,80 +174,39 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
}, },
{ {
title: t("tree-context-menu.move-to"), title: `${t("tree-context-menu.move-to")} <kbd data-command="moveNotesTo"></kbd>`,
command: "moveNotesTo", command: "moveNotesTo",
keyboardShortcut: "moveNotesTo",
uiIcon: "bx bx-transfer", uiIcon: "bx bx-transfer",
enabled: isNotRoot && !isHoisted && parentNotSearch enabled: isNotRoot && !isHoisted && parentNotSearch
}, },
{ title: t("tree-context-menu.clone-to"), command: "cloneNotesTo", keyboardShortcut: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted }, { title: `${t("tree-context-menu.clone-to")} <kbd data-command="cloneNotesTo"></kbd>`, command: "cloneNotesTo", uiIcon: "bx bx-duplicate", enabled: isNotRoot && !isHoisted },
{ {
title: t("tree-context-menu.duplicate"), title: `${t("tree-context-menu.duplicate")} <kbd data-command="duplicateSubtree">`,
command: "duplicateSubtree", command: "duplicateSubtree",
keyboardShortcut: "duplicateSubtree",
uiIcon: "bx bx-outline", uiIcon: "bx bx-outline",
enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp enabled: parentNotSearch && isNotRoot && !isHoisted && notOptionsOrHelp
}, },
{ {
title: !isArchived ? t("tree-context-menu.archive") : t("tree-context-menu.unarchive"), title: `${t("tree-context-menu.delete")} <kbd data-command="deleteNotes"></kbd>`,
uiIcon: !isArchived ? "bx bx-archive" : "bx bx-archive-out",
enabled: canToggleArchived,
handler: () => {
if (!selectedNotes.length) return;
if (selectedNotes.length == 1) {
const note = selectedNotes[0];
if (!isArchived) {
attributes.addLabel(note.noteId, "archived");
} else {
attributes.removeOwnedLabelByName(note, "archived");
}
} else {
const noteIds = selectedNotes.map(note => note.noteId);
if (!isArchived) {
executeBulkActions(noteIds, [{
name: "addLabel", labelName: "archived"
}]);
} else {
executeBulkActions(noteIds, [{
name: "deleteLabel", labelName: "archived"
}]);
}
}
}
},
{
title: t("tree-context-menu.delete"),
command: "deleteNotes", command: "deleteNotes",
keyboardShortcut: "deleteNotes",
uiIcon: "bx bx-trash destructive-action-icon", uiIcon: "bx bx-trash destructive-action-icon",
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
}, },
{ kind: "separator"}, { title: "----" },
(notOptionsOrHelp && selectedNotes.length === 1) ? {
kind: "custom",
componentFn: () => {
return NoteColorPicker({note});
}
} : null,
{ kind: "separator" },
{ title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, { title: t("tree-context-menu.import-into-note"), command: "importIntoNote", uiIcon: "bx bx-import", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
{ title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp }, { title: t("tree-context-menu.export"), command: "exportNote", uiIcon: "bx bx-export", enabled: notSearch && noSelectedNotes && notOptionsOrHelp },
{ kind: "separator" }, { title: "----" },
{ {
title: t("tree-context-menu.search-in-subtree"), title: `${t("tree-context-menu.search-in-subtree")} <kbd data-command="searchInSubtree"></kbd>`,
command: "searchInSubtree", command: "searchInSubtree",
keyboardShortcut: "searchInSubtree",
uiIcon: "bx bx-search", uiIcon: "bx bx-search",
enabled: notSearch && noSelectedNotes enabled: notSearch && noSelectedNotes
} }

View File

@@ -1,6 +1,7 @@
import appContext from "./components/app_context.js"; import appContext from "./components/app_context.js";
import noteAutocompleteService from "./services/note_autocomplete.js"; import noteAutocompleteService from "./services/note_autocomplete.js";
import glob from "./services/glob.js"; import glob from "./services/glob.js";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css"; import "boxicons/css/boxicons.min.css";
import "autocomplete.js/index_jquery.js"; import "autocomplete.js/index_jquery.js";

View File

@@ -1,157 +0,0 @@
@import "boxicons/css/boxicons.min.css";
:root {
--print-font-size: 11pt;
--ck-content-color-image-caption-background: transparent !important;
}
html,
body {
width: 100%;
height: 100%;
color: black;
}
@page {
margin: 2cm;
}
.note-list-widget.full-height,
.note-list-widget.full-height .note-list-widget-content {
height: unset !important;
}
.component {
contain: none !important;
}
body[data-note-type="text"] .ck-content {
font-size: var(--print-font-size);
text-align: justify;
}
.ck-content figcaption {
font-style: italic;
}
.ck-content a {
text-decoration: none;
}
.ck-content a:not([href^="#root/"]) {
text-decoration: underline;
color: #374a75;
}
.ck-content .todo-list__label * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@supports selector(.todo-list__label__description:has(*)) and (height: 1lh) {
.ck-content .todo-list__label__description {
/* The percentage of the line height that the check box occupies */
--box-ratio: 0.75;
/* The size of the gap between the check box and the caption */
--box-text-gap: 0.25em;
--box-size: calc(1lh * var(--box-ratio));
--box-vert-offset: calc((1lh - var(--box-size)) / 2);
display: inline-block;
padding-inline-start: calc(var(--box-size) + var(--box-text-gap));
/* Source: https://pictogrammers.com/library/mdi/icon/checkbox-blank-outline/ */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3e%3cpath d='M19%2c3H5C3.89%2c3 3%2c3.89 3%2c5V19A2%2c2 0 0%2c0 5%2c21H19A2%2c2 0 0%2c0 21%2c19V5C21%2c3.89 20.1%2c3 19%2c3M19%2c5V19H5V5H19Z' /%3e%3c/svg%3e");
background-position: 0 var(--box-vert-offset);
background-size: var(--box-size);
background-repeat: no-repeat;
}
.ck-content .todo-list__label:has(input[type="checkbox"]:checked) .todo-list__label__description {
/* Source: https://pictogrammers.com/library/mdi/icon/checkbox-outline/ */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3e%3cpath d='M19%2c3H5A2%2c2 0 0%2c0 3%2c5V19A2%2c2 0 0%2c0 5%2c21H19A2%2c2 0 0%2c0 21%2c19V5A2%2c2 0 0%2c0 19%2c3M19%2c5V19H5V5H19M10%2c17L6%2c13L7.41%2c11.58L10%2c14.17L16.59%2c7.58L18%2c9' /%3e%3c/svg%3e");
}
.ck-content .todo-list__label input[type="checkbox"] {
display: none !important;
}
}
/* #region Footnotes */
.footnote-reference a,
.footnote-back-link a {
text-decoration: none !important;
}
li.footnote-item {
position: relative;
width: fit-content;
}
.ck-content .footnote-back-link {
margin-right: 0.25em;
}
.ck-content .footnote-content {
display: inline-block;
width: unset;
}
/* #endregion */
/* #region Widows and orphans */
p,
blockquote {
widows: 4;
orphans: 4;
}
pre > code {
widows: 6;
orphans: 6;
overflow: auto;
white-space: pre-wrap !important;
}
h1,
h2,
h3,
h4,
h5,
h6 {
page-break-after: avoid;
break-after: avoid;
}
/* #endregion */
/* #region Tables */
.table thead th,
.table td,
.table th {
/* Fix center vertical alignment of table cells */
vertical-align: middle;
}
pre {
box-shadow: unset !important;
border: 0.75pt solid gray !important;
border-radius: 2pt !important;
}
th,
span[style] {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* #endregion */
/* #region Page breaks */
.page-break {
page-break-after: always;
break-after: always;
}
.page-break > *,
.page-break::after {
display: none !important;
}
/* #endregion */

View File

@@ -1,151 +0,0 @@
import FNote from "./entities/fnote";
import { render } from "preact";
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
import content_renderer from "./services/content_renderer";
import { dynamicRequire, isElectron } from "./services/utils";
import { applyInlineMermaid } from "./services/content_renderer_text";
interface RendererProps {
note: FNote;
onReady: () => void;
onProgressChanged?: (progress: number) => void;
}
async function main() {
const notePath = window.location.hash.substring(1);
const noteId = notePath.split("/").at(-1);
if (!noteId) return;
await import("./print.css");
const froca = (await import("./services/froca")).default;
const note = await froca.getNote(noteId);
render(<App note={note} noteId={noteId} />, document.body);
}
function App({ note, noteId }: { note: FNote | null | undefined, noteId: string }) {
const sentReadyEvent = useRef(false);
const onProgressChanged = useCallback((progress: number) => {
if (isElectron()) {
const { ipcRenderer } = dynamicRequire('electron');
ipcRenderer.send("print-progress", progress);
} else {
window.dispatchEvent(new CustomEvent("note-load-progress", { detail: { progress } }));
}
}, []);
const onReady = useCallback(() => {
if (sentReadyEvent.current) return;
window.dispatchEvent(new Event("note-ready"));
window._noteReady = true;
sentReadyEvent.current = true;
}, []);
const props: RendererProps | undefined | null = note && { note, onReady, onProgressChanged };
if (!note || !props) return <Error404 noteId={noteId} />
useLayoutEffect(() => {
document.body.dataset.noteType = note.type;
}, [ note ]);
return (
<>
{note.type === "book"
? <CollectionRenderer {...props} />
: <SingleNoteRenderer {...props} />
}
</>
);
}
function SingleNoteRenderer({ note, onReady }: RendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
async function load() {
if (note.type === "text") {
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
}
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
const container = containerRef.current!;
container.replaceChildren(...$renderedContent);
// Wait for all images to load.
const images = Array.from(container.querySelectorAll("img"));
await Promise.all(
images.map(img => {
if (img.complete) return Promise.resolve();
return new Promise<void>(resolve => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), { once: true });
});
})
);
// Initialize mermaid.
if (note.type === "text") {
await applyInlineMermaid(container);
}
// Check custom CSS.
await loadCustomCss(note);
}
load().then(() => requestAnimationFrame(onReady))
}, [ note ]);
return <>
<h1>{note.title}</h1>
<main ref={containerRef} />
</>;
}
function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps) {
const viewType = useNoteViewType(note);
return <CustomNoteList
viewType={viewType}
isEnabled
note={note}
notePath={note.getBestNotePath().join("/")}
ntxId="print"
highlightedTokens={null}
media="print"
onReady={async () => {
await loadCustomCss(note);
onReady();
}}
onProgressChanged={onProgressChanged}
/>;
}
function Error404({ noteId }: { noteId: string }) {
return (
<main>
<p>The note you are trying to print could not be found.</p>
<small>{noteId}</small>
</main>
)
}
async function loadCustomCss(note: FNote) {
const printCssNotes = await note.getRelationTargets("printCss");
let loadPromises: JQueryPromise<void>[] = [];
for (const printCssNote of printCssNotes) {
if (!printCssNote || (printCssNote.type !== "code" && printCssNote.mime !== "text/css")) continue;
const linkEl = document.createElement("link");
linkEl.href = `/api/notes/${printCssNote.noteId}/download`;
linkEl.rel = "stylesheet";
const promise = $.Deferred();
loadPromises.push(promise.promise());
linkEl.onload = () => promise.resolve();
document.head.appendChild(linkEl);
}
await Promise.allSettled(loadPromises);
}
main();

View File

@@ -1,15 +1,5 @@
import $ from "jquery"; import $ from "jquery";
async function loadBootstrap() {
if (document.body.dir === "rtl") {
await import("bootstrap/dist/css/bootstrap.rtl.min.css");
} else {
await import("bootstrap/dist/css/bootstrap.min.css");
}
}
(window as any).$ = $; (window as any).$ = $;
(window as any).jQuery = $; (window as any).jQuery = $;
await loadBootstrap();
$("body").show(); $("body").show();

View File

@@ -90,8 +90,7 @@ const HIDDEN_ATTRIBUTES = [
"viewType", "viewType",
"geolocation", "geolocation",
"docName", "docName",
"webViewSrc", "webViewSrc"
"archived"
]; ];
async function renderNormalAttributes(note: FNote) { async function renderNormalAttributes(note: FNote) {

View File

@@ -22,15 +22,6 @@ export async function setLabel(noteId: string, name: string, value: string = "",
}); });
} }
export async function setRelation(noteId: string, name: string, value: string = "", isInheritable = false) {
await server.put(`notes/${noteId}/set-attribute`, {
type: "relation",
name: name,
value: value,
isInheritable
});
}
async function removeAttributeById(noteId: string, attributeId: string) { async function removeAttributeById(noteId: string, attributeId: string) {
await server.remove(`notes/${noteId}/attributes/${attributeId}`); await server.remove(`notes/${noteId}/attributes/${attributeId}`);
} }
@@ -60,23 +51,6 @@ function removeOwnedLabelByName(note: FNote, labelName: string) {
return false; return false;
} }
/**
* Removes a relation identified by its name from the given note, if it exists. Note that the relation must be owned, i.e.
* it will not remove inherited attributes.
*
* @param note the note from which to remove the relation.
* @param relationName the name of the relation to remove.
* @returns `true` if an attribute was identified and removed, `false` otherwise.
*/
function removeOwnedRelationByName(note: FNote, relationName: string) {
const relation = note.getOwnedRelation(relationName);
if (relation) {
removeAttributeById(note.noteId, relation.attributeId);
return true;
}
return false;
}
/** /**
* Sets the attribute of the given note to the provided value if its truthy, or removes the attribute if the value is falsy. * Sets the attribute of the given note to the provided value if its truthy, or removes the attribute if the value is falsy.
* For an attribute with an empty value, pass an empty string instead. * For an attribute with an empty value, pass an empty string instead.
@@ -126,7 +100,9 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
} }
} }
if (attrRow.isInheritable) { // TODO: This doesn't seem right.
//@ts-ignore
if (this.isInheritable) {
for (const owningNote of owningNotes) { for (const owningNote of owningNotes) {
if (owningNote.hasAncestor(attrNote.noteId, true)) { if (owningNote.hasAncestor(attrNote.noteId, true)) {
return true; return true;
@@ -140,10 +116,8 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
export default { export default {
addLabel, addLabel,
setLabel, setLabel,
setRelation,
setAttribute, setAttribute,
removeAttributeById, removeAttributeById,
removeOwnedLabelByName, removeOwnedLabelByName,
removeOwnedRelationByName,
isAffecting isAffecting
}; };

View File

@@ -176,6 +176,11 @@ async function moveNodeUpInHierarchy(node: Fancytree.FancytreeNode) {
toastService.showError(resp.message); toastService.showError(resp.message);
return; return;
} }
if (!hoistedNoteService.isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
node.getParent().folder = false;
node.getParent().renderTitle();
}
} }
function filterSearchBranches(branchIds: string[]) { function filterSearchBranches(branchIds: string[]) {
@@ -205,7 +210,7 @@ function makeToast(id: string, message: string): ToastOptions {
} }
ws.subscribeToMessages(async (message) => { ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "deleteNotes") { if (message.taskType !== "deleteNotes") {
return; return;
} }
@@ -223,7 +228,7 @@ ws.subscribeToMessages(async (message) => {
}); });
ws.subscribeToMessages(async (message) => { ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "undeleteNotes") { if (message.taskType !== "undeleteNotes") {
return; return;
} }

View File

@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { Bundle, executeBundle } from "./bundle";
import { buildNote } from "../test/easy-froca";
describe("Script bundle", () => {
it("dayjs is available", async () => {
const script = /* js */`return api.dayjs().format("YYYY-MM-DD");`;
const bundle = getBundle(script);
const result = await executeBundle(bundle, null, $());
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("dayjs is-same-or-before plugin exists", async () => {
const script = /* js */`return api.dayjs("2023-10-01").isSameOrBefore(api.dayjs("2023-10-02"));`;
const bundle = getBundle(script);
const result = await executeBundle(bundle, null, $());
expect(result).toBe(true);
});
});
function getBundle(script: string) {
const id = buildNote({
title: "Script note"
}).noteId;
const bundle: Bundle = {
script: [
'',
`apiContext.modules['${id}'] = { exports: {} };`,
`return await ((async function(exports, module, require, api) {`,
`try {`,
`${script}`,
`;`,
`} catch (e) { throw new Error(\"Load of script note \\\"Client\\\" (${id}) failed with: \" + e.message); }`,
`for (const exportKey in exports) module.exports[exportKey] = exports[exportKey];`,
`return module.exports;`,
`}).call({}, {}, apiContext.modules['${id}'], apiContext.require([]), apiContext.apis['${id}']));`,
''
].join('\n'),
html: "",
noteId: id,
allNoteIds: [ id ]
};
return bundle;
}

View File

@@ -27,7 +27,7 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
return await executeBundle(bundle, originEntity); return await executeBundle(bundle, originEntity);
} }
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) { async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container); const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
try { try {

View File

@@ -2,31 +2,32 @@ import renderService from "./render.js";
import protectedSessionService from "./protected_session.js"; import protectedSessionService from "./protected_session.js";
import protectedSessionHolder from "./protected_session_holder.js"; import protectedSessionHolder from "./protected_session_holder.js";
import openService from "./open.js"; import openService from "./open.js";
import froca from "./froca.js";
import utils from "./utils.js"; import utils from "./utils.js";
import linkService from "./link.js";
import treeService from "./tree.js";
import FNote from "../entities/fnote.js"; import FNote from "../entities/fnote.js";
import FAttachment from "../entities/fattachment.js"; import FAttachment from "../entities/fattachment.js";
import imageContextMenuService from "../menus/image_context_menu.js"; import imageContextMenuService from "../menus/image_context_menu.js";
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js"; import { applySingleBlockSyntaxHighlight, formatCodeBlocks } from "./syntax_highlight.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js"; import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import renderDoc from "./doc_renderer.js"; import renderDoc from "./doc_renderer.js";
import { t } from "../services/i18n.js"; import { t } from "../services/i18n.js";
import WheelZoom from 'vanilla-js-wheel-zoom'; import WheelZoom from 'vanilla-js-wheel-zoom';
import { renderMathInElement } from "./math.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import renderText from "./content_renderer_text.js";
let idCounter = 1; let idCounter = 1;
export interface RenderOptions { interface Options {
tooltip?: boolean; tooltip?: boolean;
trim?: boolean; trim?: boolean;
imageHasZoom?: boolean; imageHasZoom?: boolean;
/** If enabled, it will prevent the default behavior in which an empty note would display a list of children. */
noChildrenList?: boolean;
} }
const CODE_MIME_TYPES = new Set(["application/json"]); const CODE_MIME_TYPES = new Set(["application/json"]);
export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: RenderOptions = {}) { async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: Options = {}) {
options = Object.assign( options = Object.assign(
{ {
@@ -41,7 +42,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
const $renderedContent = $('<div class="rendered-content">'); const $renderedContent = $('<div class="rendered-content">');
if (type === "text" || type === "book") { if (type === "text" || type === "book") {
await renderText(entity, $renderedContent, options); await renderText(entity, $renderedContent);
} else if (type === "code") { } else if (type === "code") {
await renderCode(entity, $renderedContent); await renderCode(entity, $renderedContent);
} else if (["image", "canvas", "mindMap"].includes(type)) { } else if (["image", "canvas", "mindMap"].includes(type)) {
@@ -113,6 +114,32 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
}; };
} }
async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>) {
// entity must be FNote
const blob = await note.getBlob();
if (blob && !utils.isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => treeService.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
await linkService.loadReferenceLinkTitle($(el));
}
await formatCodeBlocks($renderedContent);
} else if (note instanceof FNote) {
await renderChildrenList($renderedContent, note);
}
}
/** /**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type. * Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/ */
@@ -134,7 +161,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime)); await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
} }
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) { function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
const encodedTitle = encodeURIComponent(entity.title); const encodedTitle = encodeURIComponent(entity.title);
let url; let url;
@@ -229,19 +256,8 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
</button> </button>
`); `);
$downloadButton.on("click", (e) => { $downloadButton.on("click", () => openService.downloadFileNote(entity.noteId));
e.stopPropagation(); $openButton.on("click", () => openService.openNoteExternally(entity.noteId, entity.mime));
openService.downloadFileNote(entity.noteId)
});
$openButton.on("click", async (e) => {
const iconEl = $openButton.find("> .bx");
iconEl.removeClass("bx bx-link-external");
iconEl.addClass("bx bx-loader spin");
e.stopPropagation();
await openService.openNoteExternally(entity.noteId, entity.mime)
iconEl.removeClass("bx bx-loader spin");
iconEl.addClass("bx bx-link-external");
});
// open doesn't work for protected notes since it works through a browser which isn't in protected session // open doesn't work for protected notes since it works through a browser which isn't in protected session
$openButton.toggle(!entity.isProtected); $openButton.toggle(!entity.isProtected);
@@ -276,6 +292,40 @@ async function renderMermaid(note: FNote | FAttachment, $renderedContent: JQuery
} }
} }
/**
* @param {jQuery} $renderedContent
* @param {FNote} note
* @returns {Promise<void>}
*/
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {
return;
}
$renderedContent.css("padding", "10px");
$renderedContent.addClass("text-with-ellipsis");
if (childNoteIds.length > 10) {
childNoteIds = childNoteIds.slice(0, 10);
}
// just load the first 10 child notes
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
$renderedContent.append(
await linkService.createLink(`${note.noteId}/${childNote.noteId}`, {
showTooltip: false,
showNoteIcon: true
})
);
$renderedContent.append("<br>");
}
}
function getRenderingType(entity: FNote | FAttachment) { function getRenderingType(entity: FNote | FAttachment) {
let type: string = ""; let type: string = "";
if ("type" in entity) { if ("type" in entity) {

View File

@@ -1,126 +0,0 @@
import { formatCodeBlocks } from "./syntax_highlight.js";
import { getMermaidConfig } from "./mermaid.js";
import { renderMathInElement } from "./math.js";
import FNote from "../entities/fnote.js";
import FAttachment from "../entities/fattachment.js";
import tree from "./tree.js";
import froca from "./froca.js";
import link from "./link.js";
import { isHtmlEmpty } from "./utils.js";
import { default as content_renderer, type RenderOptions } from "./content_renderer.js";
export default async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
// entity must be FNote
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
await renderIncludedNotes($renderedContent[0]);
if ($renderedContent.find("span.math-tex").length > 0) {
renderMathInElement($renderedContent[0], { trust: true });
}
const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || "");
const referenceLinks = $renderedContent.find("a.reference-link");
const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el));
await froca.getNotes(noteIdsToPrefetch);
for (const el of referenceLinks) {
await link.loadReferenceLinkTitle($(el));
}
await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement);
await formatCodeBlocks($renderedContent);
} else if (note instanceof FNote && !options.noChildrenList) {
await renderChildrenList($renderedContent, note);
}
}
async function renderIncludedNotes(contentEl: HTMLElement) {
// TODO: Consider duplicating with server's share/content_renderer.ts.
const includeNoteEls = contentEl.querySelectorAll("section.include-note");
// Gather the list of items to load.
const noteIds: string[] = [];
for (const includeNoteEl of includeNoteEls) {
const noteId = includeNoteEl.getAttribute("data-note-id");
if (noteId) {
noteIds.push(noteId);
}
}
// Load the required notes.
await froca.getNotes(noteIds);
// Render and integrate the notes.
for (const includeNoteEl of includeNoteEls) {
const noteId = includeNoteEl.getAttribute("data-note-id");
if (!noteId) continue;
const note = froca.getNoteFromCache(noteId);
if (!note) {
console.warn(`Unable to include ${noteId} because it could not be found.`);
continue;
}
const renderedContent = (await content_renderer.getRenderedContent(note)).$renderedContent;
includeNoteEl.replaceChildren(...renderedContent);
}
}
/** Rewrite the code block from <pre><code> to <div> in order not to apply a codeblock style to it. */
export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElement) {
const mermaidBlocks = container.querySelectorAll('pre:has(code[class="language-mermaid"])');
if (!mermaidBlocks.length) return;
const nodes: HTMLElement[] = [];
for (const mermaidBlock of mermaidBlocks) {
const div = document.createElement("div");
div.classList.add("mermaid-diagram");
div.innerHTML = mermaidBlock.querySelector("code")?.innerHTML ?? "";
mermaidBlock.replaceWith(div);
nodes.push(div);
}
}
export async function applyInlineMermaid(container: HTMLDivElement) {
// Initialize mermaid
const mermaid = (await import("mermaid")).default;
mermaid.initialize(getMermaidConfig());
const nodes = Array.from(container.querySelectorAll<HTMLElement>("div.mermaid-diagram"));
try {
await mermaid.run({ nodes });
} catch (e) {
console.log(e);
}
}
async function renderChildrenList($renderedContent: JQuery<HTMLElement>, note: FNote) {
let childNoteIds = note.getChildNoteIds();
if (!childNoteIds.length) {
return;
}
$renderedContent.css("padding", "10px");
$renderedContent.addClass("text-with-ellipsis");
if (childNoteIds.length > 10) {
childNoteIds = childNoteIds.slice(0, 10);
}
// just load the first 10 child notes
const childNotes = await froca.getNotes(childNoteIds);
for (const childNote of childNotes) {
$renderedContent.append(
await link.createLink(`${note.noteId}/${childNote.noteId}`, {
showTooltip: false,
showNoteIcon: true
})
);
$renderedContent.append("<br>");
}
}

View File

@@ -1,90 +1,26 @@
import clsx from "clsx";
import {readCssVar} from "../utils/css-var";
import Color, { ColorInstance } from "color";
const registeredClasses = new Set<string>(); const registeredClasses = new Set<string>();
const colorsWithHue = new Set<string>();
// Read the color lightness limits defined in the theme as CSS variables function createClassForColor(color: string | null) {
if (!color?.trim()) {
return "";
}
const lightThemeColorMaxLightness = readCssVar( const normalizedColorName = color.replace(/[^a-z0-9]/gi, "");
document.documentElement,
"tree-item-light-theme-max-color-lightness"
).asNumber(70);
const darkThemeColorMinLightness = readCssVar( if (!normalizedColorName.trim()) {
document.documentElement, return "";
"tree-item-dark-theme-min-color-lightness" }
).asNumber(50);
function createClassForColor(colorString: string | null) { const className = `color-${normalizedColorName}`;
if (!colorString?.trim()) return "";
const color = parseColor(colorString);
if (!color) return "";
const className = `color-${color.hex().substring(1)}`;
if (!registeredClasses.has(className)) { if (!registeredClasses.has(className)) {
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!, // make the active fancytree selector more specific than the normal color setting
darkThemeColorMinLightness!); $("head").append(`<style>.${className}, span.fancytree-active.${className} { color: ${color} !important; }</style>`);
const hue = getHue(color);
$("head").append(`<style>
.${className}, span.fancytree-active.${className} {
--light-theme-custom-color: ${adjustedColor.lightThemeColor};
--dark-theme-custom-color: ${adjustedColor.darkThemeColor};
--custom-color-hue: ${hue ?? 'unset'};
}
</style>`);
registeredClasses.add(className); registeredClasses.add(className);
if (hue !== undefined) {
colorsWithHue.add(className);
}
} }
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue"); return className;
}
function parseColor(color: string) {
try {
return Color(color);
} catch (ex) {
console.error(ex);
}
}
/**
* Returns a pair of colors — one optimized for light themes and the other for dark themes, derived
* from the specified color to maintain sufficient contrast with each theme.
* The adjustment is performed by limiting the colors lightness in the CIELAB color space,
* according to the lightThemeMaxLightness and darkThemeMinLightness parameters.
*/
function adjustColorLightness(color: ColorInstance, lightThemeMaxLightness: number, darkThemeMinLightness: number) {
const labColor = color.lab();
const lightness = labColor.l();
// For the light theme, limit the maximum lightness
const lightThemeColor = labColor.l(Math.min(lightness, lightThemeMaxLightness)).hex();
// For the dark theme, limit the minimum lightness
const darkThemeColor = labColor.l(Math.max(lightness, darkThemeMinLightness)).hex();
return {lightThemeColor, darkThemeColor};
}
/** Returns the hue of the specified color, or undefined if the color is grayscale. */
function getHue(color: ColorInstance) {
const hslColor = color.hsl();
if (hslColor.saturationl() > 0) {
return hslColor.hue();
}
}
export function getReadableTextColor(bgColor: string) {
const colorInstance = Color(bgColor);
return colorInstance.isLight() ? "#000" : "#fff";
} }
export default { export default {

View File

@@ -1,4 +1,4 @@
import { dayjs } from "@triliumnext/commons"; import dayjs from "dayjs";
import type { FNoteRow } from "../entities/fnote.js"; import type { FNoteRow } from "../entities/fnote.js";
import froca from "./froca.js"; import froca from "./froca.js";
import server from "./server.js"; import server from "./server.js";

View File

@@ -12,7 +12,7 @@
* @param whether to execute at the beginning (`false`) * @param whether to execute at the beginning (`false`)
* @api public * @api public
*/ */
function debounce<T>(func: (...args: any[]) => T, waitMs: number, immediate: boolean = false) { function debounce<T>(func: (...args: unknown[]) => T, waitMs: number, immediate: boolean = false) {
let timeout: any; // TODO: fix once we split client and server. let timeout: any; // TODO: fix once we split client and server.
let args: unknown[] | null; let args: unknown[] | null;
let context: unknown; let context: unknown;

View File

@@ -60,7 +60,7 @@ async function confirmDeleteNoteBoxWithNote(title: string) {
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res })); return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
} }
export async function prompt(props: PromptDialogOptions) { async function prompt(props: PromptDialogOptions) {
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res })); return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
} }

View File

@@ -1,5 +1,4 @@
import type FNote from "../entities/fnote.js"; import type FNote from "../entities/fnote.js";
import { applyReferenceLinks } from "../widgets/type_widgets/text/read_only_helper.js";
import { getCurrentLanguage } from "./i18n.js"; import { getCurrentLanguage } from "./i18n.js";
import { formatCodeBlocks } from "./syntax_highlight.js"; import { formatCodeBlocks } from "./syntax_highlight.js";
@@ -11,18 +10,18 @@ export default function renderDoc(note: FNote) {
if (docName) { if (docName) {
// find doc based on language // find doc based on language
const url = getUrl(docName, getCurrentLanguage()); const url = getUrl(docName, getCurrentLanguage());
$content.load(url, async (response, status) => { $content.load(url, (response, status) => {
// fallback to english doc if no translation available // fallback to english doc if no translation available
if (status === "error") { if (status === "error") {
const fallbackUrl = getUrl(docName, "en"); const fallbackUrl = getUrl(docName, "en");
$content.load(fallbackUrl, async () => { $content.load(fallbackUrl, () => {
await processContent(fallbackUrl, $content) processContent(fallbackUrl, $content)
resolve($content); resolve($content);
}); });
return; return;
} }
await processContent(url, $content); processContent(url, $content);
resolve($content); resolve($content);
}); });
} else { } else {
@@ -33,7 +32,7 @@ export default function renderDoc(note: FNote) {
}); });
} }
async function processContent(url: string, $content: JQuery<HTMLElement>) { function processContent(url: string, $content: JQuery<HTMLElement>) {
const dir = url.substring(0, url.lastIndexOf("/")); const dir = url.substring(0, url.lastIndexOf("/"));
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks. // Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
@@ -43,15 +42,12 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
}); });
formatCodeBlocks($content); formatCodeBlocks($content);
// Apply reference links.
await applyReferenceLinks($content[0]);
} }
function getUrl(docNameValue: string, language: string) { function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works. // Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20"); docNameValue = docNameValue.replaceAll(" ", "%20");
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath; const basePath = window.glob.isDev ? new URL(window.glob.assetPath).pathname : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`; return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
} }

View File

@@ -1,8 +1,16 @@
import ws from "./ws.js"; import ws from "./ws.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import { OpenedFileUpdateStatus } from "@triliumnext/commons";
const fileModificationStatus: Record<string, Record<string, OpenedFileUpdateStatus>> = { // TODO: Deduplicate
interface Message {
type: string;
entityType: string;
entityId: string;
lastModifiedMs: number;
filePath: string;
}
const fileModificationStatus: Record<string, Record<string, Message>> = {
notes: {}, notes: {},
attachments: {} attachments: {}
}; };
@@ -31,7 +39,7 @@ function ignoreModification(entityType: string, entityId: string) {
delete fileModificationStatus[entityType][entityId]; delete fileModificationStatus[entityType][entityId];
} }
ws.subscribeToMessages(async message => { ws.subscribeToMessages(async (message: Message) => {
if (message.type !== "openedFileUpdated") { if (message.type !== "openedFileUpdated") {
return; return;
} }

View File

@@ -13,7 +13,7 @@ export interface Froca {
getBlob(entityType: string, entityId: string): Promise<FBlob | null>; getBlob(entityType: string, entityId: string): Promise<FBlob | null>;
getNote(noteId: string, silentNotFoundError?: boolean): Promise<FNote | null>; getNote(noteId: string, silentNotFoundError?: boolean): Promise<FNote | null>;
getNoteFromCache(noteId: string): FNote | undefined; getNoteFromCache(noteId: string): FNote;
getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[]; getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[];
getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise<FNote[]>; getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise<FNote[]>;

View File

@@ -40,23 +40,20 @@ class FrocaImpl implements Froca {
constructor() { constructor() {
this.initializedPromise = this.loadInitialTree(); this.initializedPromise = this.loadInitialTree();
this.#clear();
} }
async loadInitialTree() { async loadInitialTree() {
const resp = await server.get<SubtreeResponse>("tree"); const resp = await server.get<SubtreeResponse>("tree");
// clear the cache only directly before adding new content which is important for e.g., switching to protected session // clear the cache only directly before adding new content which is important for e.g., switching to protected session
this.#clear();
this.addResp(resp);
}
#clear() {
this.notes = {}; this.notes = {};
this.branches = {}; this.branches = {};
this.attributes = {}; this.attributes = {};
this.attachments = {}; this.attachments = {};
this.blobPromises = {}; this.blobPromises = {};
this.addResp(resp);
} }
async loadSubTree(subTreeNoteId: string) { async loadSubTree(subTreeNoteId: string) {
@@ -288,7 +285,7 @@ class FrocaImpl implements Froca {
return (await this.getNotes([noteId], silentNotFoundError))[0]; return (await this.getNotes([noteId], silentNotFoundError))[0];
} }
getNoteFromCache(noteId: string): FNote | undefined { getNoteFromCache(noteId: string) {
if (!noteId) { if (!noteId) {
throw new Error("Empty noteId"); throw new Error("Empty noteId");
} }

View File

@@ -8,7 +8,6 @@ import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js"; import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
import type { default as FNote, FNoteRow } from "../entities/fnote.js"; import type { default as FNote, FNoteRow } from "../entities/fnote.js";
import type { EntityChange } from "../server_types.js"; import type { EntityChange } from "../server_types.js";
import type { OptionNames } from "@triliumnext/commons";
async function processEntityChanges(entityChanges: EntityChange[]) { async function processEntityChanges(entityChanges: EntityChange[]) {
const loadResults = new LoadResults(entityChanges); const loadResults = new LoadResults(entityChanges);
@@ -31,8 +30,9 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
continue; // only noise continue; // only noise
} }
options.set(attributeEntity.name as OptionNames, attributeEntity.value); options.set(attributeEntity.name, attributeEntity.value);
loadResults.addOption(attributeEntity.name as OptionNames);
loadResults.addOption(attributeEntity.name);
} else if (ec.entityName === "attachments") { } else if (ec.entityName === "attachments") {
processAttachment(loadResults, ec); processAttachment(loadResults, ec);
} else if (ec.entityName === "blobs") { } else if (ec.entityName === "blobs") {

View File

@@ -11,16 +11,16 @@ import RightPanelWidget from "../widgets/right_panel_widget.js";
import ws from "./ws.js"; import ws from "./ws.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js"; import NoteContextAwareWidget from "../widgets/note_context_aware_widget.js";
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js"; import BasicWidget from "../widgets/basic_widget.js";
import SpacedUpdate from "./spaced_update.js"; import SpacedUpdate from "./spaced_update.js";
import shortcutService from "./shortcuts.js"; import shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js"; import dialogService from "./dialog.js";
import type FNote from "../entities/fnote.js"; import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { dayjs } from "@triliumnext/commons"; import dayjs from "dayjs";
import type NoteContext from "../components/note_context.js"; import type NoteContext from "../components/note_context.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type Component from "../components/component.js"; import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
/** /**
* A whole number * A whole number
@@ -316,7 +316,7 @@ export interface Api {
* Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the * Get access to the widget handling note detail. Methods like `getWidgetType()` and `getTypeWidget()` to get to the
* implementation of actual widget type. * implementation of actual widget type.
*/ */
getActiveNoteDetailWidget(): Promise<ReactWrappedWidget>; getActiveNoteDetailWidget(): Promise<NoteDetailWidget>;
/** /**
* @returns returns a note path of active note or null if there isn't active note * @returns returns a note path of active note or null if there isn't active note
*/ */
@@ -455,7 +455,7 @@ export interface Api {
/** /**
* Log given message to the log pane in UI * Log given message to the log pane in UI
*/ */
log(message: string | object): void; log(message: string): void;
} }
/** /**
@@ -696,7 +696,7 @@ function FrontendScriptApi(this: Api, startNote: FNote, currentNote: FNote, orig
this.log = (message) => { this.log = (message) => {
const { noteId } = this.startNote; const { noteId } = this.startNote;
message = `${utils.now()}: ${formatLogMessage(message)}`; message = `${utils.now()}: ${message}`;
console.log(`Script ${noteId}: ${message}`); console.log(`Script ${noteId}: ${message}`);

View File

@@ -0,0 +1,28 @@
/**
* 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 "../widgets/basic_widget.js";
export type { default as FAttachment } from "../entities/fattachment.js";
export type { default as FAttribute } from "../entities/fattribute.js";
export type { default as FBranch } from "../entities/fbranch.js";
export type { default as FNote } from "../entities/fnote.js";
export type { Api } from "./frontend_script_api.js";
export type { default as NoteContextAwareWidget } from "../widgets/note_context_aware_widget.js";
export type { default as RightPanelWidget } from "../widgets/right_panel_widget.js";
import FrontendScriptApi, { type Api } from "./frontend_script_api.js";
//@ts-expect-error
export const api: Api = new FrontendScriptApi();

View File

@@ -20,6 +20,9 @@ function setupGlobs() {
window.glob.froca = froca; window.glob.froca = froca;
window.glob.treeCache = froca; // compatibility for CKEditor builds for a while window.glob.treeCache = froca; // compatibility for CKEditor builds for a while
// for CKEditor integration (button on block toolbar)
window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline");
window.onerror = function (msg, url, lineNo, columnNo, error) { window.onerror = function (msg, url, lineNo, columnNo, error) {
const string = String(msg).toLowerCase(); const string = String(msg).toLowerCase();

View File

@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
describe("i18n", () => { describe("i18n", () => {
it("translations are valid JSON", () => { it("translations are valid JSON", () => {
for (const locale of LOCALES) { for (const locale of LOCALES) {
if (locale.contentOnly || locale.id === "en_rtl") { if (locale.contentOnly) {
continue; continue;
} }

View File

@@ -2,7 +2,7 @@ import options from "./options.js";
import i18next from "i18next"; import i18next from "i18next";
import i18nextHttpBackend from "i18next-http-backend"; import i18nextHttpBackend from "i18next-http-backend";
import server from "./server.js"; import server from "./server.js";
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons"; import type { Locale } from "@triliumnext/commons";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
let locales: Locale[] | null; let locales: Locale[] | null;
@@ -13,7 +13,7 @@ let locales: Locale[] | null;
export let translationsInitializedPromise = $.Deferred(); export let translationsInitializedPromise = $.Deferred();
export async function initLocale() { export async function initLocale() {
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS; const locale = (options.get("locale") as string) || "en";
locales = await server.get<Locale[]>("options/locales"); locales = await server.get<Locale[]>("options/locales");
@@ -27,7 +27,6 @@ export async function initLocale() {
returnEmptyString: false returnEmptyString: false
}); });
await setDayjsLocale(locale);
translationsInitializedPromise.resolve(); translationsInitializedPromise.resolve();
} }

View File

@@ -4,7 +4,6 @@ import ws from "./ws.js";
import utils from "./utils.js"; import utils from "./utils.js";
import appContext from "../components/app_context.js"; import appContext from "../components/app_context.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { WebSocketMessage } from "@triliumnext/commons";
type BooleanLike = boolean | "true" | "false"; type BooleanLike = boolean | "true" | "false";
@@ -67,7 +66,7 @@ function makeToast(id: string, message: string): ToastOptions {
} }
ws.subscribeToMessages(async (message) => { ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "importNotes") { if (message.taskType !== "importNotes") {
return; return;
} }
@@ -88,8 +87,8 @@ ws.subscribeToMessages(async (message) => {
} }
}); });
ws.subscribeToMessages(async (message: WebSocketMessage) => { ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "importAttachments") { if (message.taskType !== "importAttachments") {
return; return;
} }

View File

@@ -1,6 +1,6 @@
import { NoteType } from "@triliumnext/commons"; import { NoteType } from "@triliumnext/commons";
import { ViewTypeOptions } from "./note_list_renderer";
import FNote from "../entities/fnote"; import FNote from "../entities/fnote";
import { ViewTypeOptions } from "../widgets/collections/interface";
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = { export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
canvas: null, canvas: null,
@@ -10,7 +10,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
file: null, file: null,
image: null, image: null,
launcher: null, launcher: null,
mermaid: "s1aBHPd79XYj", mermaid: null,
mindMap: null, mindMap: null,
noteMap: null, noteMap: null,
relationMap: null, relationMap: null,
@@ -27,8 +27,7 @@ export const byBookType: Record<ViewTypeOptions, string | null> = {
calendar: "xWbu3jpNWapp", calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29", table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc", geoMap: "81SGnPGMk7Xc",
board: "CtBQqbwXDx1w", board: "CtBQqbwXDx1w"
presentation: null
}; };
export function getHelpUrlForNote(note: FNote | null | undefined) { export function getHelpUrlForNote(note: FNote | null | undefined) {

View File

@@ -1,6 +1,6 @@
import server from "./server.js"; import server from "./server.js";
import appContext from "../components/app_context.js"; import appContext, { type CommandNames } from "../components/app_context.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js"; import shortcutService from "./shortcuts.js";
import type Component from "../components/component.js"; import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons"; import type { ActionKeyboardShortcut } from "@triliumnext/commons";
@@ -28,24 +28,14 @@ async function getActionsForScope(scope: string) {
return actions.filter((action) => action.scope === scope); return actions.filter((action) => action.scope === scope);
} }
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component, ntxId: string | null | undefined) { async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
if (!$el[0]) return [];
const actions = await getActionsForScope(scope); const actions = await getActionsForScope(scope);
const bindings: ShortcutBinding[] = [];
for (const action of actions) { for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) { for (const shortcut of action.effectiveShortcuts ?? []) {
const binding = shortcutService.bindElShortcut($el, shortcut, () => { shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
component.triggerCommand(action.actionName, { ntxId });
});
if (binding) {
bindings.push(binding);
}
} }
} }
return bindings;
} }
getActionsForScope("window").then((actions) => { getActionsForScope("window").then((actions) => {

View File

@@ -3,8 +3,16 @@ import linkContextMenuService from "../menus/link_context_menu.js";
import appContext, { type NoteCommandData } from "../components/app_context.js"; import appContext, { type NoteCommandData } from "../components/app_context.js";
import froca from "./froca.js"; import froca from "./froca.js";
import utils from "./utils.js"; import utils from "./utils.js";
import { ALLOWED_PROTOCOLS } from "@triliumnext/commons";
import { openInCurrentNoteContext } from "../components/note_context.js"; // Be consistent with `allowedSchemes` in `src\services\html_sanitizer.ts`
// TODO: Deduplicate with server once we can.
export const ALLOWED_PROTOCOLS = [
'http', 'https', 'ftp', 'ftps', 'mailto', 'data', 'evernote', 'file', 'facetime', 'gemini', 'git',
'gopher', 'imap', 'irc', 'irc6', 'jabber', 'jar', 'lastfm', 'ldap', 'ldaps', 'magnet', 'message',
'mumble', 'nfs', 'onenote', 'pop', 'rmi', 's3', 'sftp', 'skype', 'sms', 'spotify', 'steam', 'svn', 'udp',
'view-source', 'vlc', 'vnc', 'ws', 'wss', 'xmpp', 'jdbc', 'slack', 'tel', 'smb', 'zotero', 'geo',
'mid'
];
function getNotePathFromUrl(url: string) { function getNotePathFromUrl(url: string) {
const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url); const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url);
@@ -27,7 +35,8 @@ async function getLinkIcon(noteId: string, viewMode: ViewMode | undefined) {
return icon; return icon;
} }
export type ViewMode = "default" | "source" | "attachments" | "contextual-help"; // TODO: Remove `string` once all the view modes have been mapped.
type ViewMode = "default" | "source" | "attachments" | "contextual-help" | string;
export interface ViewScope { export interface ViewScope {
/** /**
@@ -150,16 +159,11 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio
$container.append($noteLink); $container.append($noteLink);
if (showNotePath) { if (showNotePath) {
let pathSegments: string[]; const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
if (notePath == "root") { resolvedPathSegments.pop(); // Remove last element
pathSegments = ["⌂"];
} else {
const resolvedPathSegments = (await treeService.resolveNotePathToSegments(notePath)) || [];
resolvedPathSegments.pop(); // Remove last element
const resolvedPath = resolvedPathSegments.join("/"); const resolvedPath = resolvedPathSegments.join("/");
pathSegments = await treeService.getNotePathTitleComponents(resolvedPath); const pathSegments = await treeService.getNotePathTitleComponents(resolvedPath);
}
if (pathSegments) { if (pathSegments) {
if (pathSegments.length) { if (pathSegments.length) {
@@ -285,7 +289,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly. * @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise. * @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
*/ */
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) { function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) { if (hrefLink?.startsWith("data:")) {
return true; return true;
} }
@@ -307,8 +311,7 @@ export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDo
// Right click is handled separately. // Right click is handled separately.
const isMiddleClick = evt && "which" in evt && evt.which === 2; const isMiddleClick = evt && "which" in evt && evt.which === 2;
const targetIsBlank = ($link?.attr("target") === "_blank"); const targetIsBlank = ($link?.attr("target") === "_blank");
const isDoubleClick = isLeftClick && evt?.type === "dblclick"; const openInNewTab = (isLeftClick && ctrlKey) || isMiddleClick || targetIsBlank;
const openInNewTab = (isLeftClick && ctrlKey) || isDoubleClick || isMiddleClick || targetIsBlank;
const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey); const activate = (isLeftClick && ctrlKey && shiftKey) || (isMiddleClick && shiftKey);
const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey; const openInNewWindow = isLeftClick && evt?.shiftKey && !ctrlKey;
@@ -323,24 +326,36 @@ export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDo
viewScope viewScope
}); });
} else if (isLeftClick) { } else if (isLeftClick) {
openInCurrentNoteContext(evt, notePath, viewScope); const ntxId = $(evt?.target as any)
.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 });
}
} }
} else if (hrefLink) { } else if (hrefLink) {
const withinEditLink = $link?.hasClass("ck-link-actions__preview"); const withinEditLink = $link?.hasClass("ck-link-actions__preview");
const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0; const outsideOfCKEditor = !$link || $link.closest("[contenteditable]").length === 0;
if (openInNewTab || openInNewWindow || (isLeftClick && (withinEditLink || outsideOfCKEditor))) { if (openInNewTab || (withinEditLink && (isLeftClick || isMiddleClick)) || (outsideOfCKEditor && (isLeftClick || isMiddleClick))) {
if (hrefLink.toLowerCase().startsWith("http") || hrefLink.startsWith("api/")) { if (hrefLink.toLowerCase().startsWith("http") || hrefLink.startsWith("api/")) {
window.open(hrefLink, "_blank"); window.open(hrefLink, "_blank");
} else if ((hrefLink.toLowerCase().startsWith("file:") || hrefLink.toLowerCase().startsWith("geo:")) && utils.isElectron()) {
const electron = utils.dynamicRequire("electron");
electron.shell.openPath(hrefLink);
} else { } else {
// Enable protocols supported by CKEditor 5 to be clickable. // Enable protocols supported by CKEditor 5 to be clickable.
if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(protocol + ":"))) { if (ALLOWED_PROTOCOLS.some((protocol) => hrefLink.toLowerCase().startsWith(protocol + ":"))) {
if ( utils.isElectron()) { window.open(hrefLink, "_blank");
const electron = utils.dynamicRequire("electron");
electron.shell.openExternal(hrefLink);
} else {
window.open(hrefLink, "_blank");
}
} }
} }
} }
@@ -467,30 +482,37 @@ function getReferenceLinkTitleSync(href: string) {
} }
} }
if (glob.device !== "print") { // TODO: Check why the event is not supported.
// TODO: Check why the event is not supported. //@ts-ignore
//@ts-ignore $(document).on("click", "a", goToLink);
$(document).on("click", "a", goToLink); // TODO: Check why the event is not supported.
// TODO: Check why the event is not supported. //@ts-ignore
//@ts-ignore $(document).on("auxclick", "a", goToLink); // to handle the middle button
$(document).on("auxclick", "a", goToLink); // to handle the middle button // TODO: Check why the event is not supported.
// TODO: Check why the event is not supported. //@ts-ignore
//@ts-ignore $(document).on("contextmenu", "a", linkContextMenu);
$(document).on("contextmenu", "a", linkContextMenu); $(document).on("dblclick", "a", (e) => {
// TODO: Check why the event is not supported. e.preventDefault();
//@ts-ignore e.stopPropagation();
$(document).on("dblclick", "a", goToLink);
$(document).on("mousedown", "a", (e) => { const $link = $(e.target).closest("a");
if (e.which === 2) {
// prevent paste on middle click const address = $link.attr("href");
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions if (address && address.startsWith("http")) {
e.preventDefault(); window.open(address, "_blank");
return false; }
} });
});
} $(document).on("mousedown", "a", (e) => {
if (e.which === 2) {
// prevent paste on middle click
// https://github.com/zadam/trilium/issues/2995
// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#preventing_default_actions
e.preventDefault();
return false;
}
});
export default { export default {
getNotePathFromUrl, getNotePathFromUrl,

View File

@@ -1,21 +1,11 @@
import type { AttachmentRow, EtapiTokenRow, NoteType, OptionNames } from "@triliumnext/commons"; import type { AttachmentRow, EtapiTokenRow } from "@triliumnext/commons";
import type { AttributeType } from "../entities/fattribute.js"; import type { AttributeType } from "../entities/fattribute.js";
import type { EntityChange } from "../server_types.js"; import type { EntityChange } from "../server_types.js";
// TODO: Deduplicate with server. // TODO: Deduplicate with server.
interface NoteRow { interface NoteRow {
blobId: string;
dateCreated: string;
dateModified: string;
isDeleted?: boolean; isDeleted?: boolean;
isProtected?: boolean;
mime: string;
noteId: string;
title: string;
type: NoteType;
utcDateCreated: string;
utcDateModified: string;
} }
// TODO: Deduplicate with BranchRow from `rows.ts`/ // TODO: Deduplicate with BranchRow from `rows.ts`/
@@ -77,7 +67,7 @@ export default class LoadResults {
private revisionRows: RevisionRow[]; private revisionRows: RevisionRow[];
private noteReorderings: string[]; private noteReorderings: string[];
private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[]; private contentNoteIdToComponentId: ContentNoteIdToComponentIdRow[];
private optionNames: OptionNames[]; private optionNames: string[];
private attachmentRows: AttachmentRow[]; private attachmentRows: AttachmentRow[];
public hasEtapiTokenChanges: boolean = false; public hasEtapiTokenChanges: boolean = false;
@@ -190,11 +180,11 @@ export default class LoadResults {
return this.contentNoteIdToComponentId.find((l) => l.noteId === noteId && l.componentId !== componentId); return this.contentNoteIdToComponentId.find((l) => l.noteId === noteId && l.componentId !== componentId);
} }
addOption(name: OptionNames) { addOption(name: string) {
this.optionNames.push(name); this.optionNames.push(name);
} }
isOptionReloaded(name: OptionNames) { isOptionReloaded(name: string) {
return this.optionNames.includes(name); return this.optionNames.includes(name);
} }

View File

@@ -0,0 +1,71 @@
import type FNote from "../entities/fnote.js";
import BoardView from "../widgets/view_widgets/board_view/index.js";
import CalendarView from "../widgets/view_widgets/calendar_view.js";
import GeoView from "../widgets/view_widgets/geo_view/index.js";
import ListOrGridView from "../widgets/view_widgets/list_or_grid_view.js";
import TableView from "../widgets/view_widgets/table_view/index.js";
import type { ViewModeArgs } from "../widgets/view_widgets/view_mode.js";
import type ViewMode from "../widgets/view_widgets/view_mode.js";
const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const;
export type ArgsWithoutNoteId = Omit<ViewModeArgs, "noteIds">;
export type ViewTypeOptions = typeof allViewTypes[number];
export default class NoteListRenderer {
private viewType: ViewTypeOptions;
private args: ArgsWithoutNoteId;
public viewMode?: ViewMode<any>;
constructor(args: ArgsWithoutNoteId) {
this.args = args;
this.viewType = this.#getViewType(args.parentNote);
}
#getViewType(parentNote: FNote): ViewTypeOptions {
const viewType = parentNote.getLabelValue("viewType");
if (!(allViewTypes as readonly string[]).includes(viewType || "")) {
// when not explicitly set, decide based on the note type
return parentNote.type === "search" ? "list" : "grid";
} else {
return viewType as ViewTypeOptions;
}
}
get isFullHeight() {
switch (this.viewType) {
case "list":
case "grid":
return false;
default:
return true;
}
}
async renderList() {
const args = this.args;
const viewMode = this.#buildViewMode(args);
this.viewMode = viewMode;
await viewMode.beforeRender();
return await viewMode.renderList();
}
#buildViewMode(args: ViewModeArgs) {
switch (this.viewType) {
case "calendar":
return new CalendarView(args);
case "table":
return new TableView(args);
case "geoMap":
return new GeoView(args);
case "board":
return new BoardView(args);
case "list":
case "grid":
default:
return new ListOrGridView(this.viewType, args);
}
}
}

View File

@@ -1,7 +1,7 @@
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import froca from "./froca.js"; import froca from "./froca.js";
import server from "./server.js"; import server from "./server.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js"; import type { MenuCommandItem, MenuItem, MenuItemBadge } from "../menus/context_menu.js";
import type { NoteType } from "../entities/fnote.js"; import type { NoteType } from "../entities/fnote.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js"; import type { TreeCommandNames } from "../menus/tree_context_menu.js";
@@ -73,7 +73,7 @@ const BETA_BADGE = {
title: t("note_types.beta-feature") title: t("note_types.beta-feature")
}; };
const SEPARATOR: MenuSeparatorItem = { kind: "separator" }; const SEPARATOR = { title: "----" };
const creationDateCache = new Map<string, Date>(); const creationDateCache = new Map<string, Date>();
let rootCreationDate: Date | undefined; let rootCreationDate: Date | undefined;
@@ -81,8 +81,8 @@ let rootCreationDate: Date | undefined;
async function getNoteTypeItems(command?: TreeCommandNames) { async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [ const items: MenuItem<TreeCommandNames>[] = [
...getBlankNoteTypes(command), ...getBlankNoteTypes(command),
...await getBuiltInTemplates(null, command, false),
...await getBuiltInTemplates(t("note_types.collections"), command, true), ...await getBuiltInTemplates(t("note_types.collections"), command, true),
...await getBuiltInTemplates(null, command, false),
...await getUserTemplates(command) ...await getUserTemplates(command)
]; ];
@@ -121,10 +121,7 @@ async function getUserTemplates(command?: TreeCommandNames) {
} }
const items: MenuItem<TreeCommandNames>[] = [ const items: MenuItem<TreeCommandNames>[] = [
{ SEPARATOR
title: t("note_type_chooser.templates"),
kind: "header"
}
]; ];
for (const templateNote of templateNotes) { for (const templateNote of templateNotes) {
@@ -161,15 +158,15 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam
if (title) { if (title) {
items.push({ items.push({
title: title, title: title,
kind: "header" enabled: false,
uiIcon: "bx bx-empty"
}); });
} else { } else {
items.push(SEPARATOR); items.push(SEPARATOR);
} }
for (const templateNote of childNotes) { for (const templateNote of childNotes) {
if (templateNote.hasLabel("collection") !== filterCollections || if (templateNote.hasLabel("collection") !== filterCollections) {
!templateNote.hasLabel("template")) {
continue; continue;
} }

View File

@@ -1,5 +1,4 @@
import utils from "./utils.js"; import utils from "./utils.js";
import options from "./options.js";
import server from "./server.js"; import server from "./server.js";
type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void; type ExecFunction = (command: string, cb: (err: string, stdout: string, stderror: string) => void) => void;
@@ -127,7 +126,7 @@ function downloadRevision(noteId: string, revisionId: string) {
/** /**
* @param url - should be without initial slash!!! * @param url - should be without initial slash!!!
*/ */
export function getUrlForDownload(url: string) { function getUrlForDownload(url: string) {
if (utils.isElectron()) { if (utils.isElectron()) {
// electron needs absolute URL, so we extract current host, port, protocol // electron needs absolute URL, so we extract current host, port, protocol
return `${getHost()}/${url}`; return `${getHost()}/${url}`;
@@ -172,21 +171,6 @@ function getHost() {
return `${url.protocol}//${url.hostname}:${url.port}`; return `${url.protocol}//${url.hostname}:${url.port}`;
} }
async function openNoteOnServer(noteId: string) {
// Get the sync server host from options
const syncServerHost = options.get("syncServerHost");
if (!syncServerHost) {
console.error("No sync server host configured");
return;
}
const url = new URL(`#root/${noteId}`, syncServerHost).toString();
// Use window.open to ensure link opens in external browser in Electron
window.open(url, '_blank', 'noopener,noreferrer');
}
async function openDirectory(directory: string) { async function openDirectory(directory: string) {
try { try {
if (utils.isElectron()) { if (utils.isElectron()) {
@@ -214,6 +198,5 @@ export default {
openAttachmentExternally, openAttachmentExternally,
openNoteCustom, openNoteCustom,
openAttachmentCustom, openAttachmentCustom,
openNoteOnServer,
openDirectory openDirectory
}; };

View File

@@ -20,7 +20,7 @@ class Options {
this.arr = arr; this.arr = arr;
} }
get(key: OptionNames) { get(key: string) {
return this.arr?.[key] as string; return this.arr?.[key] as string;
} }
@@ -40,7 +40,7 @@ class Options {
} }
} }
getInt(key: OptionNames) { getInt(key: string) {
const value = this.arr?.[key]; const value = this.arr?.[key];
if (typeof value === "number") { if (typeof value === "number") {
return value; return value;
@@ -52,7 +52,7 @@ class Options {
return null; return null;
} }
getFloat(key: OptionNames) { getFloat(key: string) {
const value = this.arr?.[key]; const value = this.arr?.[key];
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
@@ -60,15 +60,15 @@ class Options {
return parseFloat(value); return parseFloat(value);
} }
is(key: OptionNames) { is(key: string) {
return this.arr[key] === "true"; return this.arr[key] === "true";
} }
set(key: OptionNames, value: OptionValue) { set(key: string, value: OptionValue) {
this.arr[key] = value; this.arr[key] = value;
} }
async save(key: OptionNames, value: OptionValue) { async save(key: string, value: OptionValue) {
this.set(key, value); this.set(key, value);
const payload: Record<string, OptionValue> = {}; const payload: Record<string, OptionValue> = {};
@@ -85,7 +85,7 @@ class Options {
await server.put<void>("options", newValues); await server.put<void>("options", newValues);
} }
async toggle(key: OptionNames) { async toggle(key: string) {
await this.save(key, (!this.is(key)).toString()); await this.save(key, (!this.is(key)).toString());
} }
} }

View File

@@ -41,17 +41,6 @@ function parse(value: string) {
return defObj; return defObj;
} }
/**
* For an attribute definition name (e.g. `label:TEST:TEST1`), extracts its type (label) and name (TEST:TEST1).
* @param definitionAttrName the attribute definition name, without the leading `#` (e.g. `label:TEST:TEST1`)
* @return a tuple of [type, name].
*/
export function extractAttributeDefinitionTypeAndName(definitionAttrName: string): [ "label" | "relation", string ] {
const valueType = definitionAttrName.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttrName.substring(valueType.length + 1);
return [ valueType, valueName ];
}
export default { export default {
parse parse
}; };

View File

@@ -107,11 +107,11 @@ function makeToast(message: Message, title: string, text: string): ToastOptions
} }
ws.subscribeToMessages(async (message) => { ws.subscribeToMessages(async (message) => {
if (!("taskType" in message) || message.taskType !== "protectNotes") { if (message.taskType !== "protectNotes") {
return; return;
} }
const isProtecting = message.data?.protect; const isProtecting = message.data.protect;
const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title"); const title = isProtecting ? t("protected_session.protecting-title") : t("protected_session.unprotecting-title");
if (message.type === "taskError") { if (message.type === "taskError") {

View File

@@ -1,5 +1,5 @@
import options from "./options.js"; import options from "./options.js";
import Split from "@triliumnext/split.js"; import Split from "split.js"
export const DEFAULT_GUTTER_SIZE = 5; export const DEFAULT_GUTTER_SIZE = 5;
@@ -10,10 +10,6 @@ let leftInstance: ReturnType<typeof Split> | null;
let rightPaneWidth: number; let rightPaneWidth: number;
let rightInstance: ReturnType<typeof Split> | null; let rightInstance: ReturnType<typeof Split> | null;
const noteSplitMap = new Map<string[], ReturnType<typeof Split> | undefined>(); // key: a group of ntxIds, value: the corresponding Split instance
const noteSplitRafMap = new Map<string[], number>();
let splitNoteContainer: HTMLElement | undefined;
function setupLeftPaneResizer(leftPaneVisible: boolean) { function setupLeftPaneResizer(leftPaneVisible: boolean) {
if (leftInstance) { if (leftInstance) {
leftInstance.destroy(); leftInstance.destroy();
@@ -46,7 +42,6 @@ function setupLeftPaneResizer(leftPaneVisible: boolean) {
sizes: [leftPaneWidth, restPaneWidth], sizes: [leftPaneWidth, restPaneWidth],
gutterSize: DEFAULT_GUTTER_SIZE, gutterSize: DEFAULT_GUTTER_SIZE,
minSize: [150, 300], minSize: [150, 300],
rtl: glob.isRtl,
onDragEnd: (sizes) => { onDragEnd: (sizes) => {
leftPaneWidth = Math.round(sizes[0]); leftPaneWidth = Math.round(sizes[0]);
options.save("leftPaneWidth", Math.round(sizes[0])); options.save("leftPaneWidth", Math.round(sizes[0]));
@@ -80,7 +75,6 @@ function setupRightPaneResizer() {
sizes: [100 - rightPaneWidth, rightPaneWidth], sizes: [100 - rightPaneWidth, rightPaneWidth],
gutterSize: DEFAULT_GUTTER_SIZE, gutterSize: DEFAULT_GUTTER_SIZE,
minSize: [300, 180], minSize: [300, 180],
rtl: glob.isRtl,
onDragEnd: (sizes) => { onDragEnd: (sizes) => {
rightPaneWidth = Math.round(sizes[1]); rightPaneWidth = Math.round(sizes[1]);
options.save("rightPaneWidth", Math.round(sizes[1])); options.save("rightPaneWidth", Math.round(sizes[1]));
@@ -89,87 +83,7 @@ function setupRightPaneResizer() {
} }
} }
function findKeyByNtxId(ntxId: string): string[] | undefined {
// Find the corresponding key in noteSplitMap based on ntxId
for (const key of noteSplitMap.keys()) {
if (key.includes(ntxId)) return key;
}
return undefined;
}
function setupNoteSplitResizer(ntxIds: string[]) {
let targetNtxIds: string[] | undefined;
for (const ntxId of ntxIds) {
targetNtxIds = findKeyByNtxId(ntxId);
if (targetNtxIds) break;
}
if (targetNtxIds) {
noteSplitMap.get(targetNtxIds)?.destroy();
for (const id of ntxIds) {
if (!targetNtxIds.includes(id)) {
targetNtxIds.push(id)
};
}
} else {
targetNtxIds = [...ntxIds];
}
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
function delNoteSplitResizer(ntxIds: string[]) {
let targetNtxIds = findKeyByNtxId(ntxIds[0]);
if (!targetNtxIds) {
return;
}
noteSplitMap.get(targetNtxIds)?.destroy();
noteSplitMap.delete(targetNtxIds);
targetNtxIds = targetNtxIds.filter(id => !ntxIds.includes(id));
if (targetNtxIds.length >= 2) {
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
}
function moveNoteSplitResizer(ntxId: string) {
const targetNtxIds = findKeyByNtxId(ntxId);
if (!targetNtxIds) {
return;
}
noteSplitMap.get(targetNtxIds)?.destroy();
noteSplitMap.set(targetNtxIds, undefined);
createSplitInstance(targetNtxIds);
}
function createSplitInstance(targetNtxIds: string[]) {
const prevRafId = noteSplitRafMap.get(targetNtxIds);
if (prevRafId) {
cancelAnimationFrame(prevRafId);
}
const rafId = requestAnimationFrame(() => {
splitNoteContainer = splitNoteContainer ?? $("#center-pane").find(".split-note-container-widget")[0];
const splitPanels = [...splitNoteContainer.querySelectorAll<HTMLElement>(':scope > .note-split')]
.filter(el => targetNtxIds.includes(el.getAttribute('data-ntx-id') ?? ""));
const splitInstance = Split(splitPanels, {
rtl: glob.isRtl,
gutterSize: DEFAULT_GUTTER_SIZE,
minSize: 150,
});
noteSplitMap.set(targetNtxIds, splitInstance);
noteSplitRafMap.delete(targetNtxIds);
});
noteSplitRafMap.set(targetNtxIds, rafId);
}
export default { export default {
setupLeftPaneResizer, setupLeftPaneResizer,
setupRightPaneResizer, setupRightPaneResizer
setupNoteSplitResizer,
delNoteSplitResizer,
moveNoteSplitResizer
}; };

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js"; import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js";
// Mock utils module // Mock utils module
vi.mock("./utils.js", () => ({ vi.mock("./utils.js", () => ({
@@ -119,6 +119,11 @@ describe("shortcuts", () => {
metaKey: options.metaKey || false metaKey: options.metaKey || false
} as KeyboardEvent); } as KeyboardEvent);
it("should match simple key shortcuts", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(true);
});
it("should match shortcuts with modifiers", () => { it("should match shortcuts with modifiers", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true }); const event = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(event, "ctrl+a")).toBe(true); expect(matchesShortcut(event, "ctrl+a")).toBe(true);
@@ -143,28 +148,6 @@ describe("shortcuts", () => {
expect(matchesShortcut(event, "a")).toBe(false); expect(matchesShortcut(event, "a")).toBe(false);
}); });
it("should not match when no modifiers are used", () => {
const event = createKeyboardEvent({ key: "a", code: "KeyA" });
expect(matchesShortcut(event, "a")).toBe(false);
});
it("should match some keys even with no modifiers", () => {
// Bare function keys
let event = createKeyboardEvent({ key: "F1", code: "F1" });
expect(matchesShortcut(event, "F1")).toBeTruthy();
expect(matchesShortcut(event, "f1")).toBeTruthy();
// Function keys with shift
event = createKeyboardEvent({ key: "F1", code: "F1", shiftKey: true });
expect(matchesShortcut(event, "Shift+F1")).toBeTruthy();
// Special keys
for (const keyCode of [ "Delete", "Enter", "NumpadEnter" ]) {
event = createKeyboardEvent({ key: keyCode, code: keyCode });
expect(matchesShortcut(event, keyCode), `Key ${keyCode}`).toBeTruthy();
}
});
it("should handle alternative modifier names", () => { it("should handle alternative modifier names", () => {
const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true }); const ctrlEvent = createKeyboardEvent({ key: "a", code: "KeyA", ctrlKey: true });
expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true); expect(matchesShortcut(ctrlEvent, "control+a")).toBe(true);
@@ -337,36 +320,4 @@ describe("shortcuts", () => {
expect(event.preventDefault).not.toHaveBeenCalled(); expect(event.preventDefault).not.toHaveBeenCalled();
}); });
}); });
describe('isIMEComposing', () => {
it('should return true when event.isComposing is true', () => {
const event = { isComposing: true, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when keyCode is 229', () => {
const event = { isComposing: false, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return true when both isComposing is true and keyCode is 229', () => {
const event = { isComposing: true, keyCode: 229 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(true);
});
it('should return false for normal keys', () => {
const event = { isComposing: false, keyCode: 65 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should return false when isComposing is undefined and keyCode is not 229', () => {
const event = { keyCode: 13 } as KeyboardEvent;
expect(isIMEComposing(event)).toBe(false);
});
it('should handle null/undefined events gracefully', () => {
expect(isIMEComposing(null as any)).toBe(false);
expect(isIMEComposing(undefined as any)).toBe(false);
});
});
}); });

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