Merge upstream changes and resolve conflicts

This commit is contained in:
Meinzzzz 2025-12-07 23:51:41 +01:00
commit 28dd85c1d1
454 changed files with 14206 additions and 10468 deletions

View File

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

View File

@ -21,7 +21,7 @@ runs:
# Certificate setup
- name: Import Apple certificates
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v5
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
@ -30,7 +30,7 @@ runs:
- name: Install Installer certificate
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v5
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}

View File

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

334
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,334 @@
# 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
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`

View File

@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
@ -46,7 +46,7 @@ jobs:
needs:
- test_dev
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -80,7 +80,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Install dependencies

View File

@ -32,7 +32,7 @@ jobs:
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
@ -141,7 +141,7 @@ jobs:
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6

View File

@ -45,9 +45,22 @@ jobs:
image: win-signing
shell: cmd
forge_platform: win32
# Exclude ARM64 Linux from default matrix to use native runner
exclude:
- arch: arm64
os:
name: linux
# Add ARM64 Linux with native ubuntu-24.04-arm runner for better-sqlite3 compatibility
include:
- arch: arm64
os:
name: linux
image: ubuntu-24.04-arm
shell: bash
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
@ -77,7 +90,7 @@ jobs:
GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }}
- name: Publish release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
@ -109,7 +122,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Run the build
uses: ./.github/actions/build-server
@ -118,7 +131,7 @@ jobs:
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false

View File

@ -33,7 +33,7 @@ jobs:
TRILIUM_DATA_DIR: "${{ github.workspace }}/apps/server/spec/db"
TRILIUM_INTEGRATION_TEST: memory
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
filter: tree:0
fetch-depth: 0
@ -79,7 +79,7 @@ jobs:
if: failure()
uses: actions/upload-artifact@v5
with:
name: e2e report
name: e2e report ${{ matrix.arch }}
path: apps/server-e2e/test-output
- name: Kill the server

View File

@ -45,7 +45,7 @@ jobs:
forge_platform: linux
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6
@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Run the build
uses: ./.github/actions/build-server
@ -114,7 +114,7 @@ jobs:
steps:
- run: mkdir upload
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
sparse-checkout: |
docs/Release Notes
@ -127,7 +127,7 @@ jobs:
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.4.2
uses: softprops/action-gh-release@v2.5.0
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md

View File

@ -25,7 +25,7 @@ jobs:
pull-requests: write # For PR preview comments
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v6

3
.gitignore vendored
View File

@ -48,4 +48,5 @@ upload
.svelte-kit
# docs
site/
site/
apps/*/coverage

View File

@ -9,7 +9,6 @@
"tobermory.es6-string-html",
"vitest.explorer",
"yzhang.markdown-all-in-one",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
"usernamehw.errorlens"
]
}

View File

@ -36,5 +36,8 @@
"docs/**/*.png": true,
"apps/server/src/assets/doc_notes/**": true,
"apps/edit-docs/demo/**": true
}
},
"eslint.rules.customizations": [
{ "rule": "*", "severity": "warn" }
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,14 +9,14 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.22.0",
"packageManager": "pnpm@10.24.0",
"devDependencies": {
"@redocly/cli": "2.11.1",
"@redocly/cli": "2.12.3",
"archiver": "7.0.1",
"fs-extra": "11.3.2",
"react": "19.2.0",
"react-dom": "19.2.0",
"typedoc": "0.28.14",
"react": "19.2.1",
"react-dom": "19.2.1",
"typedoc": "0.28.15",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.99.5",
"version": "0.100.0",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@ -12,10 +12,10 @@
"scripts": {
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"test": "vitest",
"coverage": "vitest --coverage",
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@eslint/js": "9.39.1",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19",
@ -27,23 +27,24 @@
"@mermaid-js/layout-elk": "0.2.0",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.5.1",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@zumer/snapdom": "2.0.1",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"dayjs": "1.11.19",
"dayjs-plugin-utc": "0.1.2",
"debounce": "3.0.0",
"draggabilly": "3.0.0",
"force-graph": "1.51.0",
"globals": "16.5.0",
"i18next": "25.6.2",
"i18next": "25.7.1",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery.fancytree": "2.38.5",
@ -53,13 +54,13 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.0",
"mermaid": "11.12.1",
"mind-elixir": "5.3.6",
"marked": "17.0.1",
"mermaid": "11.12.2",
"mind-elixir": "5.3.7",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.27.2",
"react-i18next": "16.3.3",
"preact": "10.28.0",
"react-i18next": "16.4.0",
"reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
@ -73,10 +74,10 @@
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/reveal.js": "5.2.1",
"@types/reveal.js": "5.2.2",
"@types/tabulator-tables": "6.3.0",
"copy-webpack-plugin": "13.0.1",
"happy-dom": "20.0.10",
"happy-dom": "20.0.11",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.1.4"
}

View File

@ -34,6 +34,7 @@ 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";
import type { InfoProps } from "../widgets/dialogs/info.jsx";
interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer;
@ -124,7 +125,7 @@ export type CommandMappings = {
isNewNote?: boolean;
};
showPromptDialog: PromptDialogOptions;
showInfoDialog: ConfirmWithMessageOptions;
showInfoDialog: InfoProps;
showConfirmDialog: ConfirmWithMessageOptions;
showRecentChanges: CommandData & { ancestorNoteId: string };
showImportDialog: CommandData & { noteId: string };
@ -445,6 +446,7 @@ type EventMappings = {
error: string;
};
searchRefreshed: { ntxId?: string | null };
textEditorRefreshed: { ntxId?: string | null, editor: CKTextEditor };
hoistedNoteChanged: {
noteId: string;
ntxId: string | null;
@ -486,7 +488,7 @@ type EventMappings = {
relationMapResetPanZoom: { ntxId: string | null | undefined };
relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {};
activeNoteChanged: {ntxId: string | null | undefined};
showAddLinkDialog: AddLinkOpts;
showIncludeDialog: IncludeNoteOpts;
openBulkActionsDialog: {

View File

@ -321,6 +321,10 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
return false;
}
if (note.type === "search") {
return false;
}
if (!["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "")) {
return false;
}

View File

@ -165,7 +165,7 @@ export default class TabManager extends Component {
const activeNoteContext = this.getActiveContext();
this.updateDocumentTitle(activeNoteContext);
this.triggerEvent("activeNoteChanged", {}); // trigger this even in on popstate event
this.triggerEvent("activeNoteChanged", {ntxId:activeNoteContext?.ntxId}); // trigger this even in on popstate event
}
calculateHash(): string {

View File

@ -22,6 +22,7 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
appContext.setLayout(new DesktopLayout(widgetBundles));
appContext.start().catch((e) => {
toastService.showPersistent({
id: "critical-error",
title: t("toast.critical-error.title"),
icon: "alert",
message: t("toast.critical-error.message", { message: e.message })
@ -58,6 +59,7 @@ function initOnElectron() {
initDarkOrLightMode(style);
initTransparencyEffects(style, currentWindow);
initFullScreenDetection(currentWindow);
if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow);
@ -87,6 +89,11 @@ function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron
}
}
function initFullScreenDetection(currentWindow: Electron.BrowserWindow) {
currentWindow.on("enter-full-screen", () => document.body.classList.add("full-screen"));
currentWindow.on("leave-full-screen", () => document.body.classList.remove("full-screen"));
}
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
if (window.glob.platform === "win32") {
const material = style.getPropertyValue("--background-material");

View File

@ -240,7 +240,7 @@ export default class FNote {
const aNote = this.froca.getNoteFromCache(aNoteId);
if (aNote.isArchived || aNote.isHiddenCompletely()) {
if (!aNote || aNote.isArchived || aNote.isHiddenCompletely()) {
return 1;
}

View File

@ -10,7 +10,6 @@ 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";
@ -21,7 +20,6 @@ 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 PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
@ -31,7 +29,6 @@ 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";
@ -44,6 +41,9 @@ import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import utils from "../services/utils.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
export default class DesktopLayout {
@ -124,7 +124,7 @@ export default class DesktopLayout {
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(new SpacerWidget(0, 1))
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)
.child(<ClosePaneButton />)
@ -140,7 +140,7 @@ export default class DesktopLayout {
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />)
)
.child(new PromotedAttributesWidget())
.child(<PromotedAttributes />)
.child(<SqlTableSchemas />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
@ -184,14 +184,14 @@ export default class DesktopLayout {
launcherPane = new FlexContainer("row")
.css("height", "53px")
.class("horizontal")
.child(new LauncherContainer(true))
.child(<LauncherContainer isHorizontalLayout={true} />)
.child(<GlobalMenu isHorizontalLayout={true} />);
} else {
launcherPane = new FlexContainer("column")
.css("width", "53px")
.class("vertical")
.child(<GlobalMenu isHorizontalLayout={false} />)
.child(new LauncherContainer(false))
.child(<LauncherContainer isHorizontalLayout={false} />)
.child(<LeftPaneToggle isHorizontalLayout={false} />);
}

View File

@ -22,16 +22,9 @@ import RevisionsDialog from "../widgets/dialogs/revisions.js";
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
import InfoDialog from "../widgets/dialogs/info.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 CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import NoteDetail from "../widgets/NoteDetail.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
import ToastContainer from "../widgets/Toast.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@ -57,16 +50,7 @@ export function applyModals(rootContainer: RootContainer) {
.child(<ConfirmDialog />)
.child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />)
.child(new PopupEditorDialog()
.child(new FlexContainer("row")
.class("title-row")
.css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />))
.child(<StandaloneRibbonAdapter component={FormattingToolbar} />)
.child(new PromotedAttributesWidget())
.child(<NoteDetail />)
.child(<NoteList media="screen" displayOnlyCollections />))
.child(<CallToActionDialog />);
.child(<PopupEditorDialog />)
.child(<CallToActionDialog />)
.child(<ToastContainer />)
}

View File

@ -6,14 +6,12 @@ import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
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 ContentHeader from "../widgets/containers/content_header.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import RootContainer from "../widgets/containers/root_container.js";
@ -29,9 +27,13 @@ import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button
import type AppContext from "../components/app_context.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
const MOBILE_CSS = `
<style>
span.keyboard-shortcut,
kbd {
display: none;
}
@ -141,33 +143,35 @@ export default class MobileLayout {
.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")
.child(
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteTitleWidget />)
.child(<MobileDetailMenu />)
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(new PromotedAttributesWidget())
.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 />)
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.contentSized()
.css("font-size", "larger")
.css("align-items", "center")
.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 />)
)
)
)
)
@ -179,7 +183,7 @@ export default class MobileLayout {
.child(new FlexContainer("row")
.class("horizontal")
.css("height", "53px")
.child(new LauncherContainer(true))
.child(<LauncherContainer isHorizontalLayout />)
.child(<GlobalMenuWidget isHorizontalLayout />)
.id("launcher-pane"))
)

View File

@ -2,7 +2,7 @@ import { KeyboardActionNames } from "@triliumnext/commons";
import keyboardActionService, { getActionSync } from "../services/keyboard_actions.js";
import note_tooltip from "../services/note_tooltip.js";
import utils from "../services/utils.js";
import { should } from "vitest";
import { h, JSX, render } from "preact";
export interface ContextMenuOptions<T> {
x: number;
@ -15,6 +15,11 @@ export interface ContextMenuOptions<T> {
onHide?: () => void;
}
export interface CustomMenuItem {
kind: "custom",
componentFn: () => JSX.Element | null;
}
export interface MenuSeparatorItem {
kind: "separator";
}
@ -51,7 +56,7 @@ export interface MenuCommandItem<T> {
columns?: number;
}
export type MenuItem<T> = MenuCommandItem<T> | MenuSeparatorItem | MenuHeader;
export type MenuItem<T> = MenuCommandItem<T> | CustomMenuItem | MenuSeparatorItem | MenuHeader;
export type MenuHandler<T> = (item: MenuCommandItem<T>, e: JQuery.MouseDownEvent<HTMLElement, undefined, HTMLElement, HTMLElement>) => void;
export type ContextMenuEvent = PointerEvent | MouseEvent | JQuery.ContextMenuEvent;
@ -160,16 +165,19 @@ class ContextMenu {
let $group = $parent; // The current group or parent element to which items are being appended
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) {
continue;
}
// If the current item is a header, start a new group. This group will contain the
// header and the next item that follows the header.
if ("kind" in item && item.kind === "header") {
if (itemKind === "header") {
if (multicolumn && !shouldResetGroup) {
shouldStartNewGroup = true;
}
@ -195,125 +203,25 @@ class ContextMenu {
shouldStartNewGroup = false;
}
if ("kind" in item && item.kind === "separator") {
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 ("kind" in item && item.kind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text(item.title));
} else if (itemKind === "header") {
$group.append($("<h6>").addClass("dropdown-header").text((item as MenuHeader).title));
shouldResetGroup = true;
} else {
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
if (itemKind === "custom") {
// Custom menu item
$group.append(this.createCustomMenuItem(item as CustomMenuItem));
} else {
// Standard menu item
$group.append(this.createMenuItem(item as MenuCommandItem<any>));
}
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) => {
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");
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);
}
$group.append($item);
// After adding a menu item, if the previous item was a separator or header,
// reset the group so that the next item will be appended directly to the parent.
if (shouldResetGroup) {
@ -321,9 +229,126 @@ class ContextMenu {
shouldResetGroup = false;
};
}
prevItemKind = itemKind;
}
}
private createCustomMenuItem(item: CustomMenuItem) {
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>) {
const $icon = $("<span>");
if ("uiIcon" in item || "checked" in item) {
const icon = (item.checked ? "bx bx-check" : item.uiIcon);
if (icon) {
$icon.addClass(icon);
} else {
$icon.append("&nbsp;");
}
}
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() {
this.options?.onHide?.();
this.$widget.removeClass("show");

View File

@ -0,0 +1,21 @@
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

@ -0,0 +1,86 @@
: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

@ -0,0 +1,204 @@
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.

After

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 { t } from "../services/i18n.js";
import type { BrowserWindow } from "electron";
import type { CommandNames } from "../components/app_context.js";
import type { CommandNames, AppContext } from "../components/app_context.js";
function setupContextMenu() {
const electron = utils.dynamicRequire("electron");
@ -13,6 +13,8 @@ function setupContextMenu() {
// FIXME: Remove typecast once Electron is properly integrated.
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
let appContext: AppContext;
webContents.on("context-menu", (event, params) => {
const { editFlags } = params;
const hasText = params.selectionText.trim().length > 0;
@ -119,6 +121,20 @@ function setupContextMenu() {
uiIcon: "bx bx-search-alt",
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) {

View File

@ -2,26 +2,32 @@ import { t } from "../services/i18n.js";
import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js";
import appContext, { type CommandNames } from "../components/app_context.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) {
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: getItems(),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, notePath, viewScope, hoistedNoteId)
items: getItems(e),
selectMenuItemHandler: ({ command }) => handleLinkContextMenuItem(command, e, notePath, viewScope, hoistedNoteId)
});
}
function getItems(): MenuItem<CommandNames>[] {
function getItems(e: ContextMenuEvent | LeafletMouseEvent): MenuItem<CommandNames>[] {
const ntxId = getNtxId(e);
const isMobileSplitOpen = isMobile() && appContext.tabManager.getNoteContextById(ntxId).getMainContext().getSubContexts().length > 1;
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_split"), command: "openNoteInNewSplit", uiIcon: "bx bx-dock-right" },
{ 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_window"), command: "openNoteInNewWindow", uiIcon: "bx bx-window-open" },
{ title: t("link_context_menu.open_note_in_popup"), command: "openNoteInPopup", uiIcon: "bx bx-edit" }
];
}
function handleLinkContextMenuItem(command: string | undefined, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
function handleLinkContextMenuItem(command: string | undefined, e: ContextMenuEvent | LeafletMouseEvent, notePath: string, viewScope = {}, hoistedNoteId: string | null = null) {
if (!hoistedNoteId) {
hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId ?? null;
}
@ -29,15 +35,8 @@ function handleLinkContextMenuItem(command: string | undefined, notePath: string
if (command === "openNoteInNewTab") {
appContext.tabManager.openContextWithNote(notePath, { hoistedNoteId, viewScope });
} else if (command === "openNoteInNewSplit") {
const subContexts = appContext.tabManager.getActiveContext()?.getSubContexts();
if (!subContexts) {
logError("subContexts is null");
return;
}
const { ntxId } = subContexts[subContexts.length - 1];
const ntxId = getNtxId(e);
if (!ntxId) return;
appContext.triggerCommand("openNewNoteSplit", { ntxId, notePath, hoistedNoteId, viewScope });
} else if (command === "openNoteInNewWindow") {
appContext.triggerCommand("openInWindow", { notePath, hoistedNoteId, viewScope });
@ -46,6 +45,18 @@ function handleLinkContextMenuItem(command: string | undefined, notePath: string
}
}
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 {
getItems,
handleLinkContextMenuItem,

View File

@ -1,3 +1,4 @@
import NoteColorPicker from "./custom-items/NoteColorPicker.jsx";
import treeService from "../services/tree.js";
import froca from "../services/froca.js";
import clipboard from "../services/clipboard.js";
@ -139,7 +140,13 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
uiIcon: "bx bx-rename",
enabled: isNotRoot && parentNotSearch && notOptionsOrHelp
},
{ title: t("tree-context-menu.convert-to-attachment"), command: "convertNoteToAttachment", uiIcon: "bx bx-paperclip", enabled: isNotRoot && !isHoisted && notOptionsOrHelp },
{
title:
t("tree-context-menu.convert-to-attachment"),
command: "convertNoteToAttachment",
uiIcon: "bx bx-paperclip",
enabled: isNotRoot && !isHoisted && notOptionsOrHelp && selectedNotes.some(note => note.isEligibleForConversionToAttachment())
},
{ kind: "separator" },
@ -241,6 +248,15 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
enabled: isNotRoot && !isHoisted && parentNotSearch && notOptionsOrHelp
},
{ kind: "separator"},
(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 },

View File

@ -3,10 +3,13 @@ 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() {
@ -23,13 +26,21 @@ async function main() {
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 };
const props: RendererProps | undefined | null = note && { note, onReady, onProgressChanged };
if (!note || !props) return <Error404 noteId={noteId} />
@ -71,6 +82,11 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
})
);
// Initialize mermaid.
if (note.type === "text") {
await applyInlineMermaid(container);
}
// Check custom CSS.
await loadCustomCss(note);
}
@ -84,7 +100,7 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
</>;
}
function CollectionRenderer({ note, onReady }: RendererProps) {
function CollectionRenderer({ note, onReady, onProgressChanged }: RendererProps) {
const viewType = useNoteViewType(note);
return <CustomNoteList
viewType={viewType}
@ -98,6 +114,7 @@ function CollectionRenderer({ note, onReady }: RendererProps) {
await loadCustomCss(note);
onReady();
}}
onProgressChanged={onProgressChanged}
/>;
}

View File

@ -126,9 +126,7 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
}
}
// TODO: This doesn't seem right.
//@ts-ignore
if (this.isInheritable) {
if (attrRow.isInheritable) {
for (const owningNote of owningNotes) {
if (owningNote.hasAncestor(attrNote.noteId, true)) {
return true;

View File

@ -1,6 +1,6 @@
import utils from "./utils.js";
import server from "./server.js";
import toastService, { type ToastOptions } from "./toast.js";
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
import froca from "./froca.js";
import hoistedNoteService from "./hoisted_note.js";
import ws from "./ws.js";
@ -195,11 +195,11 @@ function filterRootNote(branchIds: string[]) {
});
}
function makeToast(id: string, message: string): ToastOptions {
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
return {
id: id,
id,
title: t("branches.delete-status"),
message: message,
message,
icon: "trash"
};
}
@ -216,7 +216,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("branches.delete-finished-successfully"));
toast.closeAfter = 5000;
toast.timeout = 5000;
toastService.showPersistent(toast);
}
@ -234,7 +234,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully"));
toast.closeAfter = 5000;
toast.timeout = 5000;
toastService.showPersistent(toast);
}
@ -242,7 +242,7 @@ ws.subscribeToMessages(async (message) => {
async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) {
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
prefix: prefix
prefix
});
if (!resp.success) {
@ -252,7 +252,7 @@ async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, pr
async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix?: string) {
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
prefix: prefix
prefix
});
if (!resp.success) {

View File

@ -0,0 +1,44 @@
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);
}
async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
try {
@ -36,10 +36,17 @@ async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $cont
}.call(apiContext);
} catch (e: any) {
const note = await froca.getNote(bundle.noteId);
const message = `Execution of JS note "${note?.title}" with ID ${bundle.noteId} failed with error: ${e?.message}`;
showError(message);
logError(message);
toastService.showPersistent({
id: `custom-script-failure-${note?.noteId}`,
title: t("toast.bundle-error.title"),
icon: "bx bx-error-circle",
message: t("toast.bundle-error.message", {
id: note?.noteId,
title: note?.title,
message: e.message
})
});
logError("Widget initialization failed: ", e);
}
}
@ -102,8 +109,9 @@ async function getWidgetBundlesByParent() {
const noteId = bundle.noteId;
const note = await froca.getNote(noteId);
toastService.showPersistent({
id: `custom-script-failure-${noteId}`,
title: t("toast.bundle-error.title"),
icon: "alert",
icon: "bx bx-error-circle",
message: t("toast.bundle-error.message", {
id: noteId,
title: note?.title,

View File

@ -2,24 +2,21 @@ import renderService from "./render.js";
import protectedSessionService from "./protected_session.js";
import protectedSessionHolder from "./protected_session_holder.js";
import openService from "./open.js";
import froca from "./froca.js";
import utils from "./utils.js";
import linkService from "./link.js";
import treeService from "./tree.js";
import FNote from "../entities/fnote.js";
import FAttachment from "../entities/fattachment.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { applySingleBlockSyntaxHighlight, formatCodeBlocks } from "./syntax_highlight.js";
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
import renderDoc from "./doc_renderer.js";
import { t } from "../services/i18n.js";
import WheelZoom from 'vanilla-js-wheel-zoom';
import { renderMathInElement } from "./math.js";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import renderText from "./content_renderer_text.js";
let idCounter = 1;
interface Options {
export interface RenderOptions {
tooltip?: boolean;
trim?: boolean;
imageHasZoom?: boolean;
@ -29,7 +26,7 @@ interface Options {
const CODE_MIME_TYPES = new Set(["application/json"]);
export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: Options = {}) {
export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: RenderOptions = {}) {
options = Object.assign(
{
@ -116,32 +113,6 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
};
}
async function renderText(note: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
// 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 && !options.noChildrenList) {
await renderChildrenList($renderedContent, note);
}
}
/**
* Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type.
*/
@ -163,7 +134,7 @@ async function renderCode(note: FNote | FAttachment, $renderedContent: JQuery<HT
await applySingleBlockSyntaxHighlight($codeBlock, normalizeMimeTypeForCKEditor(note.mime));
}
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: Options = {}) {
function renderImage(entity: FNote | FAttachment, $renderedContent: JQuery<HTMLElement>, options: RenderOptions = {}) {
const encodedTitle = encodeURIComponent(entity.title);
let url;
@ -305,40 +276,6 @@ 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) {
let type: string = "";
if ("type" in entity) {

View File

@ -0,0 +1,126 @@
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,7 +1,9 @@
import clsx from "clsx";
import {readCssVar} from "../utils/css-var";
import Color, { ColorInstance } from "color";
const registeredClasses = new Set<string>();
const colorsWithHue = new Set<string>();
// Read the color lightness limits defined in the theme as CSS variables
@ -26,19 +28,23 @@ function createClassForColor(colorString: string | null) {
if (!registeredClasses.has(className)) {
const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!,
darkThemeColorMinLightness!);
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: ${getHue(color) ?? 'unset'};
--custom-color-hue: ${hue ?? 'unset'};
}
</style>`);
registeredClasses.add(className);
if (hue !== undefined) {
colorsWithHue.add(className);
}
}
return className;
return clsx("use-note-color", className, colorsWithHue.has(className) && "with-hue");
}
function parseColor(color: string) {

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@ -37,8 +38,8 @@ export function closeActiveDialog() {
}
}
async function info(message: string) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
async function info(message: MessageType, extraProps?: InfoExtraProps) {
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { ...extraProps, message, callback: res }));
}
/**

View File

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

View File

@ -288,7 +288,7 @@ class FrocaImpl implements Froca {
return (await this.getNotes([noteId], silentNotFoundError))[0];
}
getNoteFromCache(noteId: string) {
getNoteFromCache(noteId: string): FNote | undefined {
if (!noteId) {
throw new Error("Empty noteId");
}

View File

@ -17,7 +17,7 @@ import shortcutService from "./shortcuts.js";
import dialogService from "./dialog.js";
import type FNote from "../entities/fnote.js";
import { t } from "./i18n.js";
import dayjs from "dayjs";
import { dayjs } from "@triliumnext/commons";
import type NoteContext from "../components/note_context.js";
import type Component from "../components/component.js";
import { formatLogMessage } from "@triliumnext/commons";
@ -77,6 +77,10 @@ export interface Api {
/**
* Entity whose event triggered this execution.
*
* <p>
* For front-end scripts, generally there's no origin entity specified since the scripts are run by the user or automatically by the UI (widgets).
* If there is an origin entity specified, then it's going to be a note entity.
*/
originEntity: unknown | null;
@ -278,12 +282,16 @@ export interface Api {
getActiveContextNote(): FNote;
/**
* @returns returns active context (split)
* Obtains the currently active/focused split in the current tab.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
*/
getActiveContext(): NoteContext;
/**
* @returns returns active main context (first split in a tab, represents the tab as a whole)
* Obtains the main context of the current tab. This is the left-most split.
*
* Note that this method does not return the note context of the "Quick edit" panel, it will return the note context behind it.
*/
getActiveMainContext(): NoteContext;

View File

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

View File

@ -1,4 +1,4 @@
import toastService, { type ToastOptions } from "./toast.js";
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
import server from "./server.js";
import ws from "./ws.js";
import utils from "./utils.js";
@ -57,11 +57,11 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
}
}
function makeToast(id: string, message: string): ToastOptions {
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
return {
id: id,
id,
title: t("import.import-status"),
message: message,
message,
icon: "plus"
};
}
@ -78,7 +78,7 @@ ws.subscribeToMessages(async (message) => {
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("import.successful"));
toast.closeAfter = 5000;
toast.timeout = 5000;
toastService.showPersistent(toast);
@ -100,7 +100,7 @@ ws.subscribeToMessages(async (message: WebSocketMessage) => {
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
} else if (message.type === "taskSucceeded") {
const toast = makeToast(message.taskId, t("import.successful"));
toast.closeAfter = 5000;
toast.timeout = 5000;
toastService.showPersistent(toast);

View File

@ -28,7 +28,7 @@ async function getActionsForScope(scope: string) {
return actions.filter((action) => action.scope === scope);
}
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component, ntxId: string | null | undefined) {
if (!$el[0]) return [];
const actions = await getActionsForScope(scope);
@ -36,7 +36,9 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
const binding = shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
const binding = shortcutService.bindElShortcut($el, shortcut, () => {
component.triggerCommand(action.actionName, { ntxId });
});
if (binding) {
bindings.push(binding);
}

View File

@ -467,28 +467,30 @@ function getReferenceLinkTitleSync(href: string) {
}
}
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("click", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("auxclick", "a", goToLink); // to handle the middle button
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("contextmenu", "a", linkContextMenu);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("dblclick", "a", goToLink);
if (glob.device !== "print") {
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("click", "a", goToLink);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("auxclick", "a", goToLink); // to handle the middle button
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("contextmenu", "a", linkContextMenu);
// TODO: Check why the event is not supported.
//@ts-ignore
$(document).on("dblclick", "a", goToLink);
$(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;
}
});
$(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 {
getNotePathFromUrl,

View File

@ -41,6 +41,17 @@ function parse(value: string) {
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 {
parse
};

View File

@ -1,7 +1,7 @@
import server from "./server.js";
import protectedSessionHolder from "./protected_session_holder.js";
import toastService from "./toast.js";
import type { ToastOptions } from "./toast.js";
import type { ToastOptionsWithRequiredId } from "./toast.js";
import ws from "./ws.js";
import appContext from "../components/app_context.js";
import froca from "./froca.js";
@ -97,7 +97,7 @@ async function protectNote(noteId: string, protect: boolean, includingSubtree: b
await server.put(`notes/${noteId}/protect/${protect ? 1 : 0}?subtree=${includingSubtree ? 1 : 0}`);
}
function makeToast(message: Message, title: string, text: string): ToastOptions {
function makeToast(message: Message, title: string, text: string): ToastOptionsWithRequiredId {
return {
id: message.taskId,
title,
@ -124,7 +124,7 @@ ws.subscribeToMessages(async (message) => {
} else if (message.type === "taskSucceeded") {
const text = isProtecting ? t("protected_session.protecting-finished-successfully") : t("protected_session.unprotecting-finished-successfully");
const toast = makeToast(message, title, text);
toast.closeAfter = 3000;
toast.timeout = 3000;
toastService.showPersistent(toast);
}

View File

@ -263,7 +263,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
const toastService = (await import("./toast.js")).default;
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
const messageStr = (typeof message === "string" ? message : JSON.stringify(message)) || "-";
if ([400, 404].includes(statusCode) && response && typeof response === "object") {
toastService.showError(messageStr);
@ -274,10 +274,22 @@ async function reportError(method: string, url: string, statusCode: number, resp
...response
});
} else {
const title = `${statusCode} ${method} ${url}`;
toastService.showErrorTitleAndMessage(title, messageStr);
const { t } = await import("./i18n.js");
if (statusCode === 400 && (url.includes("%23") || url.includes("%2F"))) {
toastService.showPersistent({
id: "trafik-blocked",
icon: "bx bx-unlink",
title: t("server.unknown_http_error_title"),
message: t("server.traefik_blocks_requests")
});
} else {
toastService.showErrorTitleAndMessage(
t("server.unknown_http_error_title"),
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
15_000);
}
const { throwError } = await import("./ws.js");
throwError(`${title} - ${message}`);
throwError(`${statusCode} ${method} ${url} - ${message}`);
}
}

View File

@ -1,7 +1,7 @@
import utils from "./utils.js";
type ElementType = HTMLElement | Document;
type Handler = (e: KeyboardEvent) => void;
export type Handler = (e: KeyboardEvent) => void;
export interface ShortcutBinding {
element: HTMLElement | Document;

View File

@ -1,3 +1,5 @@
import { signal } from "@preact/signals";
import utils from "./utils.js";
export interface ToastOptions {
@ -5,112 +7,84 @@ export interface ToastOptions {
icon: string;
title?: string;
message: string;
delay?: number;
autohide?: boolean;
closeAfter?: number;
timeout?: number;
progress?: number;
buttons?: {
text: string;
onClick: (api: { dismissToast: () => void }) => void;
}[];
}
function toast(options: ToastOptions) {
const $toast = $(options.title
? `\
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">
<span class="bx bx-${options.icon}"></span>
<span class="toast-title"></span>
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body"></div>
</div>`
: `
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-icon">
<span class="bx bx-${options.icon}"></span>
</div>
<div class="toast-body"></div>
<div class="toast-header">
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>`
);
export type ToastOptionsWithRequiredId = Omit<ToastOptions, "id"> & Required<Pick<ToastOptions, "id">>;
$toast.toggleClass("no-title", !options.title);
$toast.find(".toast-title").text(options.title ?? "");
$toast.find(".toast-body").html(options.message);
if (options.id) {
$toast.attr("id", `toast-${options.id}`);
}
$("#toast-container").append($toast);
$toast.toast({
delay: options.delay || 3000,
autohide: !!options.autohide
});
$toast.on("hidden.bs.toast", (e) => e.target.remove());
$toast.toast("show");
return $toast;
}
function showPersistent(options: ToastOptions) {
let $toast = $(`#toast-${options.id}`);
if ($toast.length > 0) {
$toast.find(".toast-body").html(options.message);
function showPersistent(options: ToastOptionsWithRequiredId) {
const existingToast = toasts.value.find(toast => toast.id === options.id);
if (existingToast) {
updateToast(options.id, options);
} else {
options.autohide = false;
$toast = toast(options);
}
if (options.closeAfter) {
setTimeout(() => $toast.remove(), options.closeAfter);
addToast(options);
}
}
function closePersistent(id: string) {
$(`#toast-${id}`).remove();
removeToastFromStore(id);
}
function showMessage(message: string, delay = 2000, icon = "check") {
function showMessage(message: string, timeout = 2000, icon = "bx bx-check") {
console.debug(utils.now(), "message:", message);
toast({
addToast({
icon,
message: message,
autohide: true,
delay
message,
timeout
});
}
export function showError(message: string, delay = 10000) {
export function showError(message: string, timeout = 10000) {
console.log(utils.now(), "error: ", message);
toast({
icon: "alert",
message: message,
autohide: true,
delay
addToast({
icon: "bx bx-error-circle",
message,
timeout
});
}
function showErrorTitleAndMessage(title: string, message: string, delay = 10000) {
function showErrorTitleAndMessage(title: string, message: string, timeout = 10000) {
console.log(utils.now(), "error: ", message);
toast({
title: title,
icon: "alert",
message: message,
autohide: true,
delay
addToast({
title,
icon: "bx bx-error-circle",
message,
timeout
});
}
//#region Toast store
export const toasts = signal<ToastOptionsWithRequiredId[]>([]);
function addToast(opts: ToastOptions) {
const id = opts.id ?? crypto.randomUUID();
const toast = { ...opts, id };
toasts.value = [ ...toasts.value, toast ];
return id;
}
function updateToast(id: string, partial: Partial<ToastOptions>) {
toasts.value = toasts.value.map(toast => {
if (toast.id === id) {
return { ...toast, ...partial }
}
return toast;
});
}
export function removeToastFromStore(id: string) {
toasts.value = toasts.value.filter(toast => toast.id !== id);
}
//#endregion
export default {
showMessage,
showError,

View File

@ -1,6 +1,7 @@
import dayjs from "dayjs";
import { dayjs } from "@triliumnext/commons";
import type { ViewScope } from "./link.js";
import FNote from "../entities/fnote";
import { snapdom } from "@zumer/snapdom";
const SVG_MIME = "image/svg+xml";
@ -150,7 +151,7 @@ export function isMac() {
export const hasTouchBar = (isMac() && isElectron());
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
}
@ -207,7 +208,7 @@ function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
return obj;
}
function randomString(len: number) {
export function randomString(len: number) {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@ -236,7 +237,7 @@ export function isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
function isDesktop() {
export function isDesktop() {
return (
window.glob?.device === "desktop" ||
// window.glob.device is not available in setup
@ -274,7 +275,7 @@ function getMimeTypeClass(mime: string) {
return `mime-${mime.toLowerCase().replace(/[\W_]+/g, "-")}`;
}
function isHtmlEmpty(html: string) {
export function isHtmlEmpty(html: string) {
if (!html) {
return true;
} else if (typeof html !== "string") {
@ -628,16 +629,69 @@ export function createImageSrcUrl(note: FNote) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
}
/**
* Given a string representation of an SVG, triggers a download of the file on the client device.
* Helper function to prepare an element for snapdom rendering.
* Handles string parsing and temporary DOM attachment for style computation.
*
* @param source - Either an SVG/HTML string to be parsed, or an existing SVG/HTML element.
* @returns An object containing the prepared element and a cleanup function.
* The cleanup function removes temporarily attached elements from the DOM,
* or is a no-op if the element was already in the DOM.
*/
function prepareElementForSnapdom(source: string | SVGElement | HTMLElement): {
element: SVGElement | HTMLElement;
cleanup: () => void;
} {
if (typeof source === 'string') {
const parser = new DOMParser();
// Detect if content is SVG or HTML
const isSvg = source.trim().startsWith('<svg');
const mimeType = isSvg ? SVG_MIME : 'text/html';
const doc = parser.parseFromString(source, mimeType);
const element = doc.documentElement;
// Temporarily attach to DOM for proper style computation
element.style.position = 'absolute';
element.style.left = '-9999px';
element.style.top = '-9999px';
document.body.appendChild(element);
return {
element,
cleanup: () => document.body.removeChild(element)
};
}
return {
element: source,
cleanup: () => {} // No-op for existing elements
};
}
/**
* Downloads an SVG using snapdom for proper rendering. Can accept either an SVG string, an SVG element, or an HTML element.
*
* @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
* @param svgContent the content of the SVG file download.
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be downloaded.
*/
function downloadSvg(nameWithoutExtension: string, svgContent: string) {
const filename = `${nameWithoutExtension}.svg`;
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
triggerDownload(filename, dataUrl);
async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
try {
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
triggerDownload(`${nameWithoutExtension}.svg`, result.url);
} finally {
cleanup();
}
}
/**
@ -658,62 +712,26 @@ function triggerDownload(fileName: string, dataUrl: string) {
document.body.removeChild(element);
}
/**
* Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device.
*
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
* Downloads an SVG as PNG using snapdom. Can accept either an SVG string, an SVG element, or an HTML element.
*
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
* @param svgContent the content of the SVG file download.
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
* @param svgSource either an SVG string, an SVGElement, or an HTMLElement to be converted to PNG.
*/
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
return new Promise<void>((resolve, reject) => {
// First, we need to determine the width and the height from the input SVG.
const result = getSizeFromSvg(svgContent);
if (!result) {
reject();
return;
}
async function downloadAsPng(nameWithoutExtension: string, svgSource: string | SVGElement | HTMLElement) {
const { element, cleanup } = prepareElementForSnapdom(svgSource);
// Convert the image to a blob.
const { width, height } = result;
// Create an image element and load the SVG.
const imageEl = new Image();
imageEl.width = width;
imageEl.height = height;
imageEl.crossOrigin = "anonymous";
imageEl.onload = () => {
try {
// Draw the image with a canvas.
const canvasEl = document.createElement("canvas");
canvasEl.width = imageEl.width;
canvasEl.height = imageEl.height;
document.body.appendChild(canvasEl);
const ctx = canvasEl.getContext("2d");
if (!ctx) {
reject();
}
ctx?.drawImage(imageEl, 0, 0);
const imgUri = canvasEl.toDataURL("image/png")
triggerDownload(`${nameWithoutExtension}.png`, imgUri);
document.body.removeChild(canvasEl);
resolve();
} catch (e) {
console.warn(e);
reject();
}
};
imageEl.onerror = (e) => reject(e);
imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
});
try {
const result = await snapdom(element, {
backgroundColor: "transparent",
scale: 2
});
const pngImg = await result.toPng();
await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src);
} finally {
cleanup();
}
}
export function getSizeFromSvg(svgContent: string) {
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
@ -925,8 +943,8 @@ export default {
areObjectsEqual,
copyHtmlToClipboard,
createImageSrcUrl,
downloadSvg,
downloadSvgAsPng,
downloadAsSvg,
downloadAsPng,
compareVersions,
isUpdateAvailable,
isLaunchBarConfig

View File

@ -25,7 +25,11 @@
--bs-body-font-weight: var(--main-font-weight) !important;
--bs-body-color: var(--main-text-color) !important;
--bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px;
--ck-mention-list-max-height: 500px;
--tn-modal-max-height: 90vh;
--tree-item-light-theme-max-color-lightness: 50;
--tree-item-dark-theme-min-color-lightness: 75;
}
body#trilium-app.motion-disabled *,
@ -212,6 +216,16 @@ input::placeholder,
background-color: var(--modal-backdrop-color) !important;
}
body.mobile .modal .modal-dialog {
left: 50%;
transform: translateX(-50%);
width: 100%;
}
body.mobile .modal .modal-content {
border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0;
}
.component {
contain: size;
}
@ -243,6 +257,11 @@ button.close:hover {
color: var(--hover-item-text-color);
}
button.custom-title-bar-button {
background: transparent;
border: unset;
}
.modal-content {
background-color: var(--modal-background-color) !important;
}
@ -439,7 +458,8 @@ body.desktop .tabulator-popup-container,
}
body.desktop .dropdown-menu:not(#context-menu-container) .dropdown-item,
body #context-menu-container .dropdown-item > span {
body #context-menu-container .dropdown-item > span,
body.mobile .dropdown .dropdown-submenu > span {
display: flex;
align-items: center;
}
@ -494,6 +514,10 @@ body #context-menu-container .dropdown-item > span {
width: 100%;
}
.dropdown-menu .note-color-picker {
padding: 4px 12px 8px 12px;
}
.cm-editor {
height: 100%;
outline: none !important;
@ -576,11 +600,6 @@ button.btn-sm {
color: var(--left-pane-text-color);
}
.btn.active:not(.btn-primary) {
background-color: var(--button-disabled-background-color) !important;
opacity: 0.4;
}
.ck.ck-block-toolbar-button {
transform: translateX(7px);
color: var(--muted-text-color);
@ -701,11 +720,6 @@ table.promoted-attributes-in-tooltip th {
z-index: 32767 !important;
}
.tooltip-trigger {
background: transparent;
pointer-events: none;
}
.bs-tooltip-bottom .tooltip-arrow::before {
border-bottom-color: var(--main-border-color) !important;
}
@ -1001,7 +1015,7 @@ div[data-notify="container"] {
font-family: var(--monospace-font-family);
}
svg.ck-icon .note-icon {
svg.ck-icon.note-icon {
color: var(--main-text-color);
font-size: 20px;
}
@ -1112,10 +1126,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
display: inline-block;
}
.note-detail-empty {
margin: 50px;
}
.modal-header {
padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */
}
@ -1125,50 +1135,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
margin: 0 12px;
}
#toast-container {
position: absolute;
width: 100%;
top: 20px;
pointer-events: none;
}
.toast {
--bs-toast-bg: var(--accented-background-color);
--bs-toast-color: var(--main-text-color);
z-index: 9999999999 !important;
pointer-events: all;
}
.toast-header {
background-color: var(--more-accented-background-color) !important;
color: var(--main-text-color) !important;
}
.toast-body {
white-space: preserve-breaks;
overflow: hidden;
}
.toast.no-title {
display: flex;
flex-direction: row;
}
.toast.no-title .toast-icon {
display: flex;
align-items: center;
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
}
.toast.no-title .toast-body {
padding-inline-start: 0;
padding-inline-end: 0;
}
.toast.no-title .toast-header {
background-color: unset !important;
}
.ck-mentions .ck-button {
font-size: var(--detail-font-size) !important;
padding: 5px;
@ -1300,11 +1266,11 @@ body.mobile #context-menu-container.mobile-bottom-menu {
inset-inline-end: 0 !important;
bottom: 0 !important;
top: unset !important;
max-height: 70vh;
max-height: var(--tn-modal-max-height);
overflow: auto !important;
user-select: none;
-webkit-user-select: none;
padding-bottom: env(safe-area-inset-bottom) !important;
padding-bottom: max(env(safe-area-inset-bottom), var(--padding, var(--menu-padding-size))) !important;
}
body.mobile .dropdown-menu {
@ -1363,6 +1329,20 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
flex-shrink: 0;
}
.right-dropdown-widget .right-dropdown-button {
position: relative;
}
.tooltip-trigger {
background: transparent;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#launcher-pane.horizontal .right-dropdown-widget {
width: 53px;
}
@ -1538,12 +1518,15 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
@media (max-width: 991px) {
body.mobile #launcher-pane .dropdown.global-menu > .dropdown-menu.show,
body.mobile #launcher-container .dropdown > .dropdown-menu.show {
--dropdown-bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
position: fixed !important;
bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size)) !important;
bottom: var(--dropdown-bottom) !important;
top: unset !important;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
transform: unset !important;
overflow-y: auto;
max-height: calc(var(--tn-modal-max-height) - var(--dropdown-bottom));
}
#mobile-sidebar-container {
@ -1578,6 +1561,14 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
width: 100%;
}
.note-split.empty-note {
--max-content-width: var(--preferred-max-content-width);
}
.note-detail-empty {
margin: 15px;
}
#mobile-sidebar-container.show #mobile-sidebar-wrapper {
transform: translateX(0);
}
@ -1640,46 +1631,6 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
body.mobile .modal-dialog.modal-dialog-scrollable {
height: unset;
}
body.mobile .revisions-dialog .modal-dialog {
height: 95vh;
}
body.mobile .revisions-dialog .modal-body {
height: 100% !important;
flex-direction: column;
padding: 0;
}
body.mobile .revisions-dialog .revision-list {
height: unset;
max-height: 20vh;
border-bottom: 1px solid var(--main-border-color) !important;
padding: 0 1em;
}
body.mobile .revisions-dialog .modal-body > .revision-content-wrapper {
flex-grow: 1;
height: 100%;
overflow: auto;
margin: 0;
}
body.mobile .revisions-dialog .modal-body > .revision-content-wrapper > div:first-of-type {
flex-direction: column;
}
body.mobile .revisions-dialog .revision-title {
font-size: 1rem;
}
body.mobile .revisions-dialog .revision-title-buttons {
text-align: center;
}
body.mobile .revisions-dialog .revision-content {
padding: 0.5em;
}
}
/* Mobile, tablet mode */
@ -1985,7 +1936,7 @@ body.electron.platform-darwin:not(.native-titlebar) .tab-row-container {
-webkit-app-region: drag;
}
body.electron.platform-darwin:not(.native-titlebar) #tab-row-left-spacer {
body.electron.platform-darwin:not(.native-titlebar):not(.full-screen) #tab-row-left-spacer {
width: 80px;
}
@ -2434,6 +2385,15 @@ footer.webview-footer button {
.admonition.caution::before { content: "\eac7"; }
.admonition.warning::before { content: "\eac5"; }
.ck-content ul.todo-list li span.todo-list__label__description {
transition: opacity 200ms ease;
}
.ck-content ul.todo-list li:has(> span.todo-list__label input[type="checkbox"]:checked) > span.todo-list__label span.todo-list__label__description {
text-decoration: line-through;
opacity: 0.6;
}
.chat-options-container {
display: flex;
margin: 5px 0;
@ -2559,9 +2519,38 @@ iframe.print-iframe {
flex-direction: column;
}
.scrolling-container > .note-detail.full-height,
.note-detail.full-height,
.scrolling-container > .note-list-widget.full-height {
position: relative;
flex-grow: 1;
width: 100%;
}
/* Calendar collection */
.calendar-view a.fc-timegrid-event,
.calendar-view a.fc-daygrid-event {
/* Workaround: set font weight only if the theme-next is not active */
font-weight: var(--root-background, 800);
}
@media (max-width: 991px) {
body.mobile {
.split-note-container-widget {
flex-direction: column !important;
.note-split {
width: 100%;
}
.note-split.visible + .note-split.visible {
border-top: 1px solid var(--main-border-color);
}
}
#root-widget.virtual-keyboard-opened .note-split:not(:focus-within) {
max-height: 80px;
opacity: 0.4;
}
}
}

View File

@ -76,6 +76,9 @@
--mermaid-theme: dark;
--native-titlebar-background: #00000000;
--calendar-coll-event-background-saturation: 30%;
--calendar-coll-event-background-lightness: 30%;
}
body ::-webkit-calendar-picker-indicator {
@ -109,3 +112,6 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6) !important;
}
.use-note-color {
--custom-color: var(--dark-theme-custom-color);
}

View File

@ -80,6 +80,9 @@ html {
--mermaid-theme: default;
--native-titlebar-background: #ffffff00;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
}
#left-pane .fancytree-node.tinted {
@ -91,4 +94,8 @@ html {
.ck-content a.reference-link > span,
.board-note {
color: var(--light-theme-custom-color, inherit);
}
.use-note-color {
--custom-color: var(--light-theme-custom-color);
}

View File

@ -41,6 +41,9 @@
--cmd-button-keyboard-shortcut-color: white;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #ffffff4e;
--button-group-active-button-text-color: white;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);
@ -98,6 +101,7 @@
--menu-item-delimiter-color: #ffffff1c;
--menu-item-group-header-color: #ffffff91;
--menu-section-background-color: #fefefe08;
--menu-submenu-mobile-background-color: rgba(0, 0, 0, 0.15);
--modal-backdrop-color: #000;
--modal-shadow-color: rgba(0, 0, 0, .5);
@ -266,6 +270,14 @@
--ck-editor-toolbar-button-on-color: white;
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
--calendar-coll-event-background-saturation: 25%;
--calendar-coll-event-background-lightness: 20%;
--calendar-coll-event-background-color: #3c3c3c;
--calendar-coll-event-text-color: white;
--calendar-coll-event-hover-filter: brightness(1.25);
--callendar-coll-event-archived-sripe-color: #00000026;
--calendar-coll-today-background-color: #ffffff08;
}
/*
@ -300,8 +312,12 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
border-color: var(--muted-text-color) !important;
}
.tinted-quick-edit-dialog {
.quick-edit-dialog-wrapper.with-hue {
--modal-background-color: hsl(var(--custom-color-hue), 8.8%, 11.2%);
--modal-border-color: hsl(var(--custom-color-hue), 9.4%, 25.1%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 13.2%, 20.8%);
}
.use-note-color {
--custom-color: var(--dark-theme-custom-color);
}

View File

@ -41,6 +41,9 @@
--cmd-button-keyboard-shortcut-color: black;
--cmd-button-disabled-opacity: 0.5;
--button-group-active-button-background: #00000026;
--button-group-active-button-text-color: black;
--icon-button-color: currentColor;
--icon-button-hover-background: var(--hover-item-background-color);
--icon-button-hover-color: var(--hover-item-text-color);
@ -265,6 +268,14 @@
--ck-editor-toolbar-button-on-color: black;
--ck-editor-toolbar-button-on-shadow: none;
--ck-editor-toolbar-dropdown-button-open-background: #0000000f;
--calendar-coll-event-background-lightness: 95%;
--calendar-coll-event-background-saturation: 80%;
--calendar-coll-event-background-color: #eaeaea;
--calendar-coll-event-text-color: black;
--calendar-coll-event-hover-filter: brightness(.95) saturate(1.25);
--callendar-coll-event-archived-sripe-color: #0000000a;
--calendar-coll-today-background-color: #00000006;
}
#left-pane .fancytree-node.tinted {
@ -276,7 +287,7 @@
--custom-bg-color: hsl(var(--custom-color-hue), 37%, 89%, 1);
}
.tinted-quick-edit-dialog {
.quick-edit-dialog-wrapper.with-hue {
--modal-background-color: hsl(var(--custom-color-hue), 56%, 96%);
--modal-border-color: hsl(var(--custom-color-hue), 33%, 41%);
--promoted-attribute-card-background-color: hsl(var(--custom-color-hue), 40%, 88%);

View File

@ -62,6 +62,7 @@
--menu-padding-size: 8px;
--menu-item-icon-vert-offset: -2px;
--menu-submenu-mobile-background-color: rgba(255, 255, 255, 0.15);
--more-accented-background-color: var(--card-background-hover-color);
@ -99,6 +100,14 @@
--tree-item-dark-theme-min-color-lightness: 65;
}
body {
user-select: none;
}
.selectable-text {
user-select: text;
}
body.backdrop-effects-disabled {
/* Backdrop effects are disabled, replace the menu background color with the
* no-backdrop fallback color */
@ -119,17 +128,6 @@ body.backdrop-effects-disabled {
font-size: 0.9rem !important;
}
body.mobile .dropdown-menu {
backdrop-filter: var(--dropdown-backdrop-filter);
border-radius: var(--dropdown-border-radius);
position: relative;
}
body.mobile .dropdown-menu .dropdown-menu {
backdrop-filter: unset !important;
border-radius: unset !important;
}
body.desktop .dropdown-menu::before,
:root .ck.ck-dropdown__panel::before,
:root .excalidraw .popover::before,
@ -157,17 +155,12 @@ body.desktop .dropdown-submenu .dropdown-menu::before {
content: unset;
}
body.mobile .dropdown-submenu .dropdown-menu {
background: transparent !important;
}
body.desktop .dropdown-submenu .dropdown-menu {
backdrop-filter: var(--dropdown-backdrop-filter);
background: transparent;
}
.dropdown-item,
body.mobile .dropdown-submenu .dropdown-toggle,
.excalidraw .context-menu .context-menu-item {
--menu-item-start-padding: 8px;
--menu-item-end-padding: 22px;
@ -201,10 +194,6 @@ body.mobile .dropdown-item:not(:last-of-type) {
margin-bottom: 0.5em;
}
body.mobile .dropdown-submenu:hover {
background: transparent !important;
}
html body .dropdown-item.disabled,
html body .dropdown-item[disabled] {
color: var(--menu-text-color) !important;
@ -321,17 +310,126 @@ body.desktop .dropdown-menu.static .dropdown-item.active {
--active-item-text-color: var(--menu-text-color);
}
/* #region Mobile tweaks for dropdown menus */
body.mobile #context-menu-cover {
transition: background-color 150ms ease-in;
&.show {
background: rgba(0, 0, 0, 0.7);
}
&.global-menu-cover {
bottom: calc(var(--mobile-bottom-offset) + var(--launcher-pane-size));
@media (min-width: 992px) {
bottom: 0;
}
}
}
body.mobile .dropdown-menu.mobile-bottom-menu,
body.mobile .dropdown.global-menu .dropdown-menu {
border-radius: var(--dropdown-border-radius) var(--dropdown-border-radius) 0 0;
}
body.mobile .dropdown-menu {
--dropdown-menu-padding-vertical: 0.7em;
--dropdown-menu-padding-horizontal: 1em;
--hover-item-background-color: var(--card-background-color);
font-size: 1em !important;
backdrop-filter: var(--dropdown-backdrop-filter);
position: relative;
.dropdown-toggle::after {
top: 0.5em;
right: var(--dropdown-menu-padding-horizontal);
transform: translateX(50%) rotate(90deg);
}
.dropdown-item.submenu-open .dropdown-toggle::after {
transform: rotate(270deg);
}
.dropdown-item,
.dropdown-custom-item {
margin-bottom: 0;
padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal) !important;
background: var(--card-background-color);
border-bottom: 1px solid var(--menu-item-delimiter-color) !important;
border-radius: 0;
}
.dropdown-item:first-of-type,
.dropdown-divider + .dropdown-item,
.dropdown-custom-item:first-of-type,
.dropdown-divider + .dropdown-custom-item {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.dropdown-item:last-of-type,
.dropdown-item:has(+ .dropdown-divider),
.dropdown-custom-item:last-of-type,
.dropdown-custom-item:has(+ .dropdown-divider) {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom: 0 !important;
}
.dropdown-divider {
visibility: hidden;
}
.dropdown-submenu {
padding: 0 !important;
backdrop-filter: unset !important;
.dropdown-toggle {
padding: var(--dropdown-menu-padding-vertical) var(--dropdown-menu-padding-horizontal);
}
.dropdown-menu {
--menu-background-color: --menu-submenu-mobile-background-color;
--bs-dropdown-divider-margin-y: 0.25rem;
border-radius: 0;
max-height: 0;
transition: max-height 100ms ease-in;
display: block !important;
&.show {
max-height: 1000px;
padding: 0.5rem 0.75rem !important;
}
}
&.submenu-open {
.dropdown-toggle {
padding-bottom: var(--dropdown-menu-padding-vertical);
}
}
}
.dropdown-custom-item:has(.note-color-picker) {
overflow-x: auto;
}
.note-color-picker {
padding: 0;
width: fit-content;
.color-cell {
--color-picker-cell-size: 26px;
flex-shrink: 0;
}
}
}
/* #endregion */
body.desktop .dropdown-menu .dropdown-toggle::after {
height: 100%;
}
body.mobile .dropdown-menu .dropdown-toggle::after {
transform: rotate(90deg);
}
body.mobile .dropdown-menu .dropdown-item.submenu-open .dropdown-toggle::after {
transform: rotate(270deg);
}
/* Dropdown item button (used in zoom buttons in global menu) */
@ -347,6 +445,12 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
outline: 2px solid var(--input-focus-outline-color) !important;
}
:root .dropdown-menu .note-color-picker {
padding: 4px 10px;
--note-color-picker-clear-color-cell-background: var(--main-text-color);
--note-color-picker-clear-color-cell-selection-outline-color: var(--main-text-color);
}
/*
* TOASTS
*/

View File

@ -25,6 +25,7 @@
.modal .modal-header .btn-close,
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button,
#toast-container .toast .toast-header .btn-close {
display: flex;
justify-content: center;
@ -55,15 +56,17 @@
font-family: boxicons;
}
.modal .modal-header .help-button {
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button {
margin-inline-end: 0;
font-size: calc(var(--modal-control-button-size) * .75);
font-size: calc(var(--modal-control-button-size) * .70);
font-family: unset;
font-weight: bold;
}
.modal .modal-header .btn-close:hover,
.modal .modal-header .help-button:hover,
.modal .modal-header .custom-title-bar-button:hover,
#toast-container .toast .toast-header .btn-close:hover {
background: var(--modal-control-button-hover-background);
color: var(--modal-control-button-hover-color);
@ -71,6 +74,7 @@
.modal .modal-header .btn-close:active,
.modal .modal-header .help-button:active,
.modal .modal-header .custom-title-bar-button:active,
#toast-container .toast .toast-header .btn-close:active {
transform: scale(.85);
}

View File

@ -17,6 +17,10 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel, .c
padding: 4px 16px;
background: var(--cmd-button-background-color);
color: var(--cmd-button-text-color);
&.dropdown-toggle-split {
min-width: unset;
}
}
button.btn.btn-primary:hover,
@ -142,6 +146,14 @@ button.btn.btn-success kbd {
outline: 2px solid var(--input-focus-outline-color);
}
/* Button groups */
/* Active button */
:root .btn-group button.btn.active {
background-color: var(--button-group-active-button-background);
color: var(--button-group-active-button-text-color);
}
/*
* Input boxes
*/

View File

@ -526,11 +526,14 @@ button.ck.ck-button:is(.ck-button-action, .ck-button-save, .ck-button-cancel).ck
.ck-mermaid__editing-view {
border: 0;
border-radius: 6px;
box-shadow: var(--code-block-box-shadow);
padding: 0;
box-shadow: var(--code-block-box-shadow);
margin-top: 2px !important;
}
:root .ck-content pre:has(> code) {
padding: 0;
}
:root .ck-content pre {
--icon-button-size: 1.8em;
--copy-button-width: var(--icon-button-size);
@ -643,7 +646,7 @@ html .note-detail-editable-text :not(figure, .include-note, hr):first-child {
}
}
.note-detail-printable:not(.word-wrap) pre code {
.ck-content:not(.word-wrap) pre code {
white-space: pre;
}

View File

@ -124,12 +124,8 @@
/* The container */
.note-split.empty-note {
--max-content-width: 70%;
}
.note-split.empty-note div.note-detail {
margin: 50px auto;
margin-inline: auto;
}
/* The search results list */

View File

@ -212,7 +212,8 @@ body[dir=ltr] #launcher-container {
}
#launcher-pane .launcher-button,
#launcher-pane .dropdown {
#launcher-pane .right-dropdown-widget,
#launcher-pane .global-menu {
width: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
height: calc(var(--launcher-pane-size) - (var(--launcher-pane-button-margin) * 2)) !important;
margin: var(--launcher-pane-button-gap) var(--launcher-pane-button-margin);
@ -345,7 +346,7 @@ body[dir=ltr] #launcher-container {
*/
.calendar-dropdown-widget {
padding: 12px;
padding: 18px;
color: var(--calendar-color);
user-select: none;
}
@ -1428,9 +1429,7 @@ div.promoted-attribute-cell .tn-checkbox {
height: 1cap;
}
/* Relocate the checkbox before the label */
div.promoted-attribute-cell.promoted-attribute-label-boolean > div:first-of-type {
order: -1;
margin-inline-end: 1.5em;
}
@ -1450,12 +1449,20 @@ div.promoted-attribute-cell .multiplicity:has(span) span {
justify-content: center;
}
div.promoted-attribute-cell.promoted-attribute-label-color {
justify-content: space-between;
}
div.promoted-attribute-cell.promoted-attribute-label-color .input-group {
width: auto;
}
/*
* Floating buttons
*/
/* Floating buttons container */
div#center-pane .floating-buttons-children {
.floating-buttons-children {
opacity: 1;
min-height: var(--floating-button-height);
transform-origin: right;
@ -1467,12 +1474,12 @@ div#center-pane .floating-buttons-children {
opacity 250ms ease-out;
}
body[dir=rtl] div#center-pane .floating-buttons-children {
body[dir=rtl] .floating-buttons-children {
transform-origin: left;
}
/* Floating buttons container (collapsed) */
div#center-pane .floating-buttons-children.temporarily-hidden {
.floating-buttons-children.temporarily-hidden {
display: flex !important;
opacity: 0;
transform: scaleX(0);

View File

@ -87,7 +87,11 @@ export function buildNote(noteDef: NoteDefinition) {
let position = 0;
for (const [ key, value ] of Object.entries(noteDef)) {
const attributeId = utils.randomString(12);
const name = key.substring(1);
let name = key.substring(1);
const isInheritable = key.endsWith("(inheritable)");
if (isInheritable) {
name = name.substring(0, name.length - "(inheritable)".length);
}
let attribute: FAttribute | null = null;
if (key.startsWith("#")) {
@ -98,7 +102,7 @@ export function buildNote(noteDef: NoteDefinition) {
name,
value,
position,
isInheritable: false
isInheritable
});
}
@ -110,7 +114,7 @@ export function buildNote(noteDef: NoteDefinition) {
name,
value,
position,
isInheritable: false
isInheritable
});
}

View File

@ -46,6 +46,8 @@ function mockServer() {
attributes: []
}
}
console.warn(`Unsupported GET to mocked server: ${url}`);
},
async post(url: string, data: object) {

View File

@ -201,8 +201,12 @@
},
"zpetne_odkazy": {
"relation": "العلاقة",
"backlink": "{{count}} رابط راجع",
"backlinks": "{{count}} روابط راجعة"
"backlink_zero": "",
"backlink_one": "{{count}} رابط راجع",
"backlink_two": "",
"backlink_few": "",
"backlink_many": "{{count}} روابط راجعة",
"backlink_other": ""
},
"note_icon": {
"category": "الفئة:",
@ -230,7 +234,6 @@
"geo-map": "الخريطة الجغرافية",
"collapse_all_notes": "طي كل الملاحظات",
"include_archived_notes": "عرض الملاحظات المؤرشفة",
"expand_all_children": "توسيع جميع العناصر الفرعية",
"presentation": "عرض تقديمي",
"invalid_view_type": "نوع العرض {{type}} غير صالح"
},
@ -945,7 +948,7 @@
"move-to-available-launchers": "نقل الى المشغلات المتوفرة",
"duplicate-launcher": "تكرار المشغل <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"editable_text": {
"auto-detect-language": "تم اكتشافه تلقائيا"
},
"classic_editor_toolbar": {

View File

@ -1,185 +1,187 @@
{
"about": {
"title": "Sobre Trilium Notes",
"homepage": "Pàgina principal:"
},
"add_link": {
"note": "Nota"
},
"branch_prefix": {
"prefix": "Prefix: ",
"save": "Desa"
},
"bulk_actions": {
"labels": "Etiquetes",
"relations": "Relacions",
"notes": "Notes",
"other": "Altres"
},
"confirm": {
"confirmation": "Confirmació",
"cancel": "Cancel·la",
"ok": "OK"
},
"delete_notes": {
"close": "Tanca",
"cancel": "Cancel·la",
"ok": "OK"
},
"export": {
"close": "Tanca",
"export": "Exporta"
},
"help": {
"troubleshooting": "Solució de problemes",
"other": "Altres"
},
"import": {
"options": "Opcions",
"import": "Importa"
},
"include_note": {
"label_note": "Nota"
},
"info": {
"closeButton": "Tanca",
"okButton": "OK"
},
"note_type_chooser": {
"templates": "Plantilles:"
},
"prompt": {
"title": "Sol·licitud",
"defaultTitle": "Sol·licitud"
},
"protected_session_password": {
"close_label": "Tanca"
},
"recent_changes": {
"undelete_link": "recuperar"
},
"revisions": {
"restore_button": "Restaura",
"delete_button": "Suprimeix",
"download_button": "Descarrega",
"mime": "MIME: ",
"preview": "Vista prèvia:"
},
"sort_child_notes": {
"title": "títol",
"ascending": "ascendent",
"descending": "descendent",
"folders": "Carpetes"
},
"upload_attachments": {
"options": "Opcions",
"upload": "Puja"
},
"attribute_detail": {
"name": "Nom",
"value": "Valor",
"promoted": "Destacat",
"promoted_alias": "Àlies",
"multiplicity": "Multiplicitat",
"label_type": "Tipus",
"text": "Text",
"number": "Número",
"boolean": "Booleà",
"date": "Data",
"time": "Hora",
"url": "URL",
"precision": "Precisió",
"digits": "dígits",
"inheritable": "Heretable",
"delete": "Suprimeix",
"color_type": "Color"
},
"rename_label": {
"to": "Per"
},
"move_note": {
"to": "a"
},
"add_relation": {
"to": "a"
},
"rename_relation": {
"to": "Per"
},
"update_relation_target": {
"to": "a"
},
"attachments_actions": {
"download": "Descarrega"
},
"calendar": {
"mon": "Dl",
"tue": "Dt",
"wed": "dc",
"thu": "Dj",
"fri": "Dv",
"sat": "Ds",
"sun": "Dg",
"january": "Gener",
"february": "Febrer",
"march": "Març",
"april": "Abril",
"may": "Maig",
"june": "Juny",
"july": "Juliol",
"august": "Agost",
"september": "Setembre",
"october": "Octubre",
"november": "Novembre",
"december": "Desembre"
},
"global_menu": {
"menu": "Menú",
"options": "Opcions",
"zoom": "Zoom",
"advanced": "Avançat",
"logout": "Tanca la sessió"
},
"zpetne_odkazy": {
"relation": "relació"
},
"note_icon": {
"category": "Categoria:",
"search": "Cerca:"
},
"basic_properties": {
"editable": "Editable",
"language": "Llengua"
},
"book_properties": {
"grid": "Graella",
"list": "Llista",
"collapse": "Replega",
"expand": "Desplega",
"calendar": "Calendari",
"table": "Taula",
"board": "Tauler"
},
"edited_notes": {
"deleted": "(suprimit)"
},
"file_properties": {
"download": "Descarrega",
"open": "Obre",
"title": "Fitxer"
},
"image_properties": {
"download": "Descarrega",
"open": "Obre",
"title": "Imatge"
},
"note_info_widget": {
"created": "Creat",
"modified": "Modificat",
"type": "Tipus",
"calculate": "calcula"
},
"note_paths": {
"archived": "Arxivat"
}
"about": {
"title": "Sobre Trilium Notes",
"homepage": "Pàgina principal:",
"app_version": "Versió de l'aplicació:",
"db_version": "Versió de la base de dades:"
},
"add_link": {
"note": "Nota"
},
"branch_prefix": {
"prefix": "Prefix: ",
"save": "Desa"
},
"bulk_actions": {
"labels": "Etiquetes",
"relations": "Relacions",
"notes": "Notes",
"other": "Altres"
},
"confirm": {
"confirmation": "Confirmació",
"cancel": "Cancel·la",
"ok": "OK"
},
"delete_notes": {
"close": "Tanca",
"cancel": "Cancel·la",
"ok": "OK"
},
"export": {
"close": "Tanca",
"export": "Exporta"
},
"help": {
"troubleshooting": "Solució de problemes",
"other": "Altres"
},
"import": {
"options": "Opcions",
"import": "Importa"
},
"include_note": {
"label_note": "Nota"
},
"info": {
"closeButton": "Tanca",
"okButton": "OK"
},
"note_type_chooser": {
"templates": "Plantilles:"
},
"prompt": {
"title": "Sol·licitud",
"defaultTitle": "Sol·licitud"
},
"protected_session_password": {
"close_label": "Tanca"
},
"recent_changes": {
"undelete_link": "recuperar"
},
"revisions": {
"restore_button": "Restaura",
"delete_button": "Suprimeix",
"download_button": "Descarrega",
"mime": "MIME: ",
"preview": "Vista prèvia:"
},
"sort_child_notes": {
"title": "títol",
"ascending": "ascendent",
"descending": "descendent",
"folders": "Carpetes"
},
"upload_attachments": {
"options": "Opcions",
"upload": "Puja"
},
"attribute_detail": {
"name": "Nom",
"value": "Valor",
"promoted": "Destacat",
"promoted_alias": "Àlies",
"multiplicity": "Multiplicitat",
"label_type": "Tipus",
"text": "Text",
"number": "Número",
"boolean": "Booleà",
"date": "Data",
"time": "Hora",
"url": "URL",
"precision": "Precisió",
"digits": "dígits",
"inheritable": "Heretable",
"delete": "Suprimeix",
"color_type": "Color"
},
"rename_label": {
"to": "Per"
},
"move_note": {
"to": "a"
},
"add_relation": {
"to": "a"
},
"rename_relation": {
"to": "Per"
},
"update_relation_target": {
"to": "a"
},
"attachments_actions": {
"download": "Descarrega"
},
"calendar": {
"mon": "Dl",
"tue": "Dt",
"wed": "dc",
"thu": "Dj",
"fri": "Dv",
"sat": "Ds",
"sun": "Dg",
"january": "Gener",
"february": "Febrer",
"march": "Març",
"april": "Abril",
"may": "Maig",
"june": "Juny",
"july": "Juliol",
"august": "Agost",
"september": "Setembre",
"october": "Octubre",
"november": "Novembre",
"december": "Desembre"
},
"global_menu": {
"menu": "Menú",
"options": "Opcions",
"zoom": "Zoom",
"advanced": "Avançat",
"logout": "Tanca la sessió"
},
"zpetne_odkazy": {
"relation": "relació"
},
"note_icon": {
"category": "Categoria:",
"search": "Cerca:"
},
"basic_properties": {
"editable": "Editable",
"language": "Llengua"
},
"book_properties": {
"grid": "Graella",
"list": "Llista",
"collapse": "Replega",
"expand": "Desplega",
"calendar": "Calendari",
"table": "Taula",
"board": "Tauler"
},
"edited_notes": {
"deleted": "(suprimit)"
},
"file_properties": {
"download": "Descarrega",
"open": "Obre",
"title": "Fitxer"
},
"image_properties": {
"download": "Descarrega",
"open": "Obre",
"title": "Imatge"
},
"note_info_widget": {
"created": "Creat",
"modified": "Modificat",
"type": "Tipus",
"calculate": "calcula"
},
"note_paths": {
"archived": "Arxivat"
}
}

View File

@ -162,7 +162,8 @@
"inPageSearch": "页面内搜索",
"newTabWithActivationNoteLink": "在新标签页打开笔记链接并激活该标签页",
"title": "资料表",
"newTabNoteLink": "在新标签页开启链接"
"newTabNoteLink": "在新标签页开启链接",
"editShortcuts": "编辑键盘快捷键"
},
"import": {
"importIntoNote": "导入到笔记",
@ -735,9 +736,8 @@
"zoom_out_title": "缩小"
},
"zpetne_odkazy": {
"backlink": "{{count}} 个反链",
"backlinks": "{{count}} 个反链",
"relation": "关系"
"relation": "关系",
"backlink_other": "{{count}} 个反链"
},
"mobile_detail_menu": {
"insert_child_note": "插入子笔记",
@ -764,7 +764,6 @@
"grid": "网格",
"list": "列表",
"collapse_all_notes": "折叠所有笔记",
"expand_all_children": "展开所有子项",
"collapse": "折叠",
"expand": "展开",
"invalid_view_type": "无效的查看类型 '{{type}}'",
@ -774,7 +773,11 @@
"geo-map": "地理地图",
"board": "看板",
"include_archived_notes": "展示归档笔记",
"presentation": "演示"
"presentation": "演示",
"expand_tooltip": "展开此集合的直接子代(单层深度)。点击右方箭头以查看更多选项。",
"expand_first_level": "展开直接子代",
"expand_nth_level": "展开 {{depth}} 层",
"expand_all_levels": "展开所有层级"
},
"edited_notes": {
"no_edited_notes_found": "今天还没有编辑过的笔记...",
@ -983,7 +986,9 @@
"placeholder": "在这里输入您的代码笔记内容..."
},
"editable_text": {
"placeholder": "在这里输入您的笔记内容..."
"placeholder": "在这里输入您的笔记内容...",
"auto-detect-language": "自动检测",
"keeps-crashing": "编辑组件时持续崩溃。请尝试重启 Trilium。如果问题仍然存在请考虑提交错误报告。"
},
"empty": {
"open_note_instruction": "通过在下面的输入框中输入笔记标题或在树中选择笔记来打开笔记。",
@ -1151,7 +1156,10 @@
"unit": "字符"
},
"code_mime_types": {
"title": "下拉菜单可用的MIME文件类型"
"title": "下拉菜单可用的MIME文件类型",
"tooltip_syntax_highlighting": "语法高亮",
"tooltip_code_block_syntax": "文本笔记中的代码块",
"tooltip_code_note_syntax": "代码笔记"
},
"vim_key_bindings": {
"use_vim_keybindings_in_code_notes": "Vim 快捷键",
@ -1463,7 +1471,7 @@
"import-into-note": "导入到笔记",
"apply-bulk-actions": "应用批量操作",
"converted-to-attachments": "{{count}} 个笔记已被转换为附件。",
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?",
"convert-to-attachment-confirm": "确定要将选中的笔记转换为其父笔记的附件吗?此操作仅适用于图像笔记,其他笔记将被跳过。",
"duplicate": "复制",
"open-in-popup": "快速编辑",
"archive": "归档",
@ -1551,7 +1559,8 @@
"refresh-saved-search-results": "刷新保存的搜索结果",
"create-child-note": "创建子笔记",
"unhoist": "取消聚焦",
"toggle-sidebar": "切换侧边栏"
"toggle-sidebar": "切换侧边栏",
"dropping-not-allowed": "不允许移动笔记到此处。"
},
"title_bar_buttons": {
"window-on-top": "保持此窗口置顶"
@ -1653,9 +1662,6 @@
"move-to-available-launchers": "移动到可用启动器",
"duplicate-launcher": "复制启动器 <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "自动检测"
},
"highlighting": {
"title": "代码块",
"description": "控制文本笔记中代码块的语法高亮,代码笔记不会受到影响。",
@ -1695,7 +1701,8 @@
"copy-link": "复制链接",
"paste": "粘贴",
"paste-as-plain-text": "以纯文本粘贴",
"search_online": "用 {{searchEngine}} 搜索 \"{{term}}\""
"search_online": "用 {{searchEngine}} 搜索 \"{{term}}\"",
"search_in_trilium": "在 Trilium 中搜索「{{term}}」"
},
"image_context_menu": {
"copy_reference_to_clipboard": "复制引用到剪贴板",
@ -1705,7 +1712,8 @@
"open_note_in_new_tab": "在新标签页中打开笔记",
"open_note_in_new_split": "在新分屏中打开笔记",
"open_note_in_new_window": "在新窗口中打开笔记",
"open_note_in_popup": "快速编辑"
"open_note_in_popup": "快速编辑",
"open_note_in_other_split": "在另一个分屏中打开笔记"
},
"electron_integration": {
"desktop-application": "桌面应用程序",
@ -1888,9 +1896,7 @@
"indexing_stopped": "索引已停止",
"indexing_in_progress": "索引进行中...",
"last_indexed": "最后索引时间",
"n_notes_queued_0": "{{ count }} 条笔记已加入索引队列",
"note_chat": "笔记聊天",
"notes_indexed_0": "{{ count }} 条笔记已索引",
"sources": "来源",
"start_indexing": "开始索引",
"use_advanced_context": "使用高级上下文",
@ -2089,7 +2095,19 @@
"read-only-info": {
"read-only-note": "当前正在查看一个只读笔记。",
"auto-read-only-note": "这条笔记以只读模式显示便于快速加载。",
"auto-read-only-learn-more": "了解更多",
"edit-note": "编辑笔记"
},
"note-color": {
"clear-color": "清除笔记颜色",
"set-color": "设置笔记颜色",
"set-custom-color": "设置自定义笔记颜色"
},
"popup-editor": {
"maximize": "切换至完整编辑器"
},
"server": {
"unknown_http_error_title": "与服务器通讯错误",
"unknown_http_error_content": "状态码: {{statusCode}}\n地址: {{method}} {{url}}\n信息: {{message}}",
"traefik_blocks_requests": "如果您使用 Traefik 反向代理,它引入了一项影响与服务器的通信重大更改。"
}
}

View File

@ -24,14 +24,6 @@
"message": "Uživatelský skript z poznámky s ID \"{{id}}\" a názvem \"{{title}}\" nemohl být spuštěn z důvodu: \n\n{{message}}"
}
},
"ai_llm": {
"n_notes_queued_0": "{{ count }} poznámka ve frontě k indexaci",
"n_notes_queued_1": "{{ count }} poznámky ve frontě k indexaci",
"n_notes_queued_2": "{{ count }} poznámek ve frontě k indexaci",
"notes_indexed_0": "{{ count }} poznámka indexována",
"notes_indexed_1": "{{ count }} poznámky indexovány",
"notes_indexed_2": "{{ count }} poznámek indexováno"
},
"add_link": {
"add_link": "Přidat odkaz",
"help_on_links": "Nápověda k odkazům",
@ -43,7 +35,7 @@
"link_title_arbitrary": "titulek odkazu může být změněn libovolně"
},
"branch_prefix": {
"prefix": "Prefix: ",
"prefix": "Předpona: ",
"save": "Uložit",
"edit_branch_prefix": "Upravit prefix větve",
"edit_branch_prefix_multiple": "Upravit prefix větve pro {{count}} větví",
@ -63,24 +55,59 @@
"bulk_actions_executed": "Hromadné akce byly úspěšně provedeny.",
"labels": "Štítky",
"relations": "Relace",
"other": "Ostatní"
"other": "Ostatní",
"none_yet": "Zatím žádné akce... přidejte akci kliknutím na jednu z dostupných výše."
},
"confirm": {
"cancel": "Zrušit",
"ok": "OK"
"ok": "OK",
"confirmation": "Potvrzení",
"are_you_sure_remove_note": "Opravdu chcete odstranit poznámku „{{title}}“ z mapy vztahů? ",
"if_you_dont_check": "Pokud tuto možnost nezaškrtnete, poznámka bude odstraněna pouze z mapy vztahů.",
"also_delete_note": "Odstraňte také poznámku"
},
"delete_notes": {
"cancel": "Zrušit",
"ok": "OK",
"close": "Zavřít"
"close": "Zavřít",
"delete_notes_preview": "Odstranit náhled poznámek",
"delete_all_clones_description": "Odstraňte také všechny klony (lze vrátit zpět v nedávných změnách)",
"erase_notes_description": "Normální (měkké) smazání pouze označí poznámky jako smazané a lze je během určité doby obnovit (v dialogovém okně posledních změn). Zaškrtnutím této možnosti se poznámky okamžitě vymažou a nebude možné je obnovit.",
"erase_notes_warning": "Trvale smažte poznámky (nelze vrátit zpět), včetně všech klonů. Tím se vynutí opětovné načtení aplikace.",
"notes_to_be_deleted": "Následující poznámky budou smazány ({{notesCount}})",
"no_note_to_delete": "Žádná poznámka nebude smazána (pouze klony).",
"broken_relations_to_be_deleted": "Následující vazby budou přerušeny a smazány ({{relationCount}})",
"deleted_relation_text": "Poznámka {{- note}} (bude smazána) je odkazována vazbou {{- relation}} pocházející z {{- source}}."
},
"export": {
"close": "Zavřít"
"close": "Zavřít",
"export_note_title": "Exportovat poznámku",
"export_type_subtree": "Tato poznámka a všechny její odvozené poznámky",
"format_html": "HTML doporučeno, protože zachovává veškeré formátování",
"format_html_zip": "HTML v archivu ZIP toto se doporučuje, protože se tak zachová veškeré formátování.",
"format_markdown": "Markdown zachovává většinu formátování.",
"format_opml": "OPML formát pro výměnu osnov pouze pro text. Formátování, obrázky a soubory nejsou zahrnuty.",
"opml_version_1": "OPML v1.0 pouze prostý text",
"opml_version_2": "OPML v2.0 umožňuje také HTML",
"export_type_single": "Pouze tato poznámka bez jejích potomků",
"export": "Exportovat",
"choose_export_type": "Nejprve vyberte typ exportu",
"export_status": "Stav exportu",
"export_in_progress": "Export probíhá: {{progressCount}}",
"export_finished_successfully": "Export byl úspěšně dokončen.",
"format_pdf": "PDF pro tisk nebo sdílení.",
"share-format": "HTML pro publikování na webu používá stejný motiv jako sdílené poznámky, ale lze jej publikovat jako statický web."
},
"clone_to": {
"clone_notes_to": "Klonovat poznámky do...",
"help_on_links": "Nápověda k odkazům",
"notes_to_clone": "Poznámky na klonování",
"search_for_note_by_its_name": "hledat poznámku dle jejího názvu"
"search_for_note_by_its_name": "hledat poznámku dle jejího názvu",
"prefix_optional": "Předpona (volitelná)",
"target_parent_note": "Zaměřit rodičovskou poznámku",
"cloned_note_prefix_title": "Klonovaná poznámka se zobrazí ve stromu poznámek s danou předponou",
"clone_to_selected_note": "Klonovat vybranou poznámku",
"no_path_to_clone_to": "Žádná cest pro klonování.",
"note_cloned": "Poznámka: „{{clonedTitle}}“ bylo naklonováno do „{{targetTitle}}“"
}
}

View File

@ -21,7 +21,7 @@
},
"bundle-error": {
"title": "Benutzerdefiniertes Skript konnte nicht geladen werden",
"message": "Skript von der Notiz mit der ID \"{{id}}\", und dem Titel \"{{title}}\" konnte nicht ausgeführt werden wegen:\n\n{{message}}"
"message": "Skript aus der Notiz \"{{title}}\" mit der ID \"{{id}}\", konnte nicht ausgeführt werden wegen:\n\n{{message}}"
}
},
"add_link": {
@ -39,7 +39,10 @@
"help_on_tree_prefix": "Hilfe zum Baumpräfix",
"prefix": "Präfix: ",
"save": "Speichern",
"branch_prefix_saved": "Zweigpräfix wurde gespeichert."
"branch_prefix_saved": "Zweigpräfix wurde gespeichert.",
"branch_prefix_saved_multiple": "Der Zweigpräfix wurde für {{count}} Zweige gespeichert.",
"edit_branch_prefix_multiple": "Branch-Präfix für {{count}} Zweige bearbeiten",
"affected_branches": "Betroffene Zweige ({{count}}):"
},
"bulk_actions": {
"bulk_actions": "Massenaktionen",
@ -684,7 +687,8 @@
"convert_into_attachment_failed": "Konvertierung der Notiz '{{title}}' fehlgeschlagen.",
"convert_into_attachment_successful": "Notiz '{{title}}' wurde als Anhang konvertiert.",
"convert_into_attachment_prompt": "Bist du dir sicher, dass du die Notiz '{{title}}' in ein Anhang der übergeordneten Notiz konvertieren möchtest?",
"print_pdf": "Export als PDF..."
"print_pdf": "Export als PDF...",
"open_note_on_server": "Öffne Notiz auf dem Server"
},
"onclick_button": {
"no_click_handler": "Das Schaltflächen-Widget „{{componentId}}“ hat keinen definierten Klick-Handler"
@ -728,9 +732,9 @@
"zoom_out_title": "Herauszoomen"
},
"zpetne_odkazy": {
"backlink": "{{count}} Rückverlinkung",
"backlinks": "{{count}} Rückverlinkungen",
"relation": "Beziehung"
"relation": "Beziehung",
"backlink_one": "{{count}} Rückverlinkung",
"backlink_other": "{{count}} Rückverlinkungen"
},
"mobile_detail_menu": {
"insert_child_note": "Untergeordnete Notiz einfügen",
@ -757,7 +761,6 @@
"grid": "Gitter",
"list": "Liste",
"collapse_all_notes": "Alle Notizen einklappen",
"expand_all_children": "Unternotizen ausklappen",
"collapse": "Einklappen",
"expand": "Ausklappen",
"invalid_view_type": "Ungültiger Ansichtstyp „{{type}}“",
@ -767,7 +770,11 @@
"geo-map": "Weltkarte",
"board": "Tafel",
"include_archived_notes": "Zeige archivierte Notizen",
"presentation": "Präsentation"
"presentation": "Präsentation",
"expand_all_levels": "Alle Ebenen erweitern",
"expand_tooltip": "Erweitert die direkten Unterelemente dieser Sammlung (eine Ebene tiefer). Für weitere Optionen auf den Pfeil rechts klicken.",
"expand_first_level": "Direkte Unterelemente erweitern",
"expand_nth_level": "{{depth}} Ebenen erweitern"
},
"edited_notes": {
"no_edited_notes_found": "An diesem Tag wurden noch keine Notizen bearbeitet...",
@ -976,7 +983,9 @@
"placeholder": "Gebe hier den Inhalt deiner Codenotiz ein..."
},
"editable_text": {
"placeholder": "Gebe hier den Inhalt deiner Notiz ein..."
"placeholder": "Gebe hier den Inhalt deiner Notiz ein...",
"auto-detect-language": "Automatisch erkannt",
"keeps-crashing": "Die Bearbeitungskomponente stürzt immer wieder ab. Bitte starten Sie Trilium neu. Wenn das Problem weiterhin besteht, erstellen Sie einen Fehlerbericht."
},
"empty": {
"open_note_instruction": "Öffne eine Notiz, indem du den Titel der Notiz in die Eingabe unten eingibst oder eine Notiz in der Baumstruktur auswählst.",
@ -1104,7 +1113,8 @@
"title": "Inhaltsbreite",
"default_description": "Trilium begrenzt standardmäßig die maximale Inhaltsbreite, um die Lesbarkeit für maximierte Bildschirme auf Breitbildschirmen zu verbessern.",
"max_width_label": "Maximale Inhaltsbreite in Pixel",
"max_width_unit": "Pixel"
"max_width_unit": "Pixel",
"centerContent": "Inhalt zentriert halten"
},
"native_title_bar": {
"title": "Native Titelleiste (App-Neustart erforderlich)",
@ -1143,7 +1153,10 @@
"unit": "Zeichen"
},
"code_mime_types": {
"title": "Verfügbare MIME-Typen im Dropdown-Menü"
"title": "Verfügbare MIME-Typen im Dropdown-Menü",
"tooltip_syntax_highlighting": "Syntaxhervorhebung",
"tooltip_code_block_syntax": "Code-Blöcke in Textnotizen",
"tooltip_code_note_syntax": "Code-Notizen"
},
"vim_key_bindings": {
"use_vim_keybindings_in_code_notes": "Verwende VIM-Tastenkombinationen in Codenotizen (kein Ex-Modus)",
@ -1510,7 +1523,8 @@
"refresh-saved-search-results": "Gespeicherte Suchergebnisse aktualisieren",
"create-child-note": "Unternotiz anlegen",
"unhoist": "Fokus verlassen",
"toggle-sidebar": "Seitenleiste ein-/ausblenden"
"toggle-sidebar": "Seitenleiste ein-/ausblenden",
"dropping-not-allowed": "Ablegen von Notizen an dieser Stelle ist nicht zulässig."
},
"title_bar_buttons": {
"window-on-top": "Dieses Fenster immer oben halten"
@ -1612,9 +1626,6 @@
"move-to-available-launchers": "Zu verfügbaren Launchern verschieben",
"duplicate-launcher": "Launcher duplizieren <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Automatisch erkannt"
},
"highlighting": {
"description": "Steuert die Syntaxhervorhebung für Codeblöcke in Textnotizen, Code-Notizen sind nicht betroffen.",
"color-scheme": "Farbschema",
@ -1654,7 +1665,8 @@
"copy-link": "Link kopieren",
"paste": "Einfügen",
"paste-as-plain-text": "Als unformatierten Text einfügen",
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten"
"search_online": "Suche nach \"{{term}}\" mit {{searchEngine}} starten",
"search_in_trilium": "Suche nach \"{{term}}\" in Trilium"
},
"image_context_menu": {
"copy_reference_to_clipboard": "Referenz in Zwischenablage kopieren",
@ -1715,10 +1727,6 @@
"help_title": "Zeige mehr Informationen zu diesem Fenster"
},
"ai_llm": {
"n_notes_queued": "{{ count }} Notiz zur Indizierung vorgemerkt",
"n_notes_queued_plural": "{{ count }} Notizen zur Indizierung vorgemerkt",
"notes_indexed": "{{ count }} Notiz indiziert",
"notes_indexed_plural": "{{ count }} Notizen indiziert",
"not_started": "Nicht gestartet",
"title": "KI Einstellungen",
"processed_notes": "Verarbeitete Notizen",
@ -2028,7 +2036,8 @@
"new-item-placeholder": "Notiz Titel eingeben...",
"add-column-placeholder": "Spaltenname eingeben...",
"edit-note-title": "Klicke zum Editieren des Notiz-Titels",
"edit-column-title": "Klicke zum Editieren des Spalten-Titels"
"edit-column-title": "Klicke zum Editieren des Spalten-Titels",
"column-already-exists": "Die Spalte ist auf dem Board bereits vorhanden."
},
"command_palette": {
"tree-action-name": "Struktur: {{name}}",
@ -2078,5 +2087,18 @@
"edit-slide": "Folie bearbeiten",
"start-presentation": "Präsentation starten",
"slide-overview": "Übersicht der Folien ein-/ausblenden"
},
"read-only-info": {
"read-only-note": "Aktuelle Notiz wird im Lese-Modus angezeigt.",
"auto-read-only-note": "Diese Notiz wird im Nur-Lesen-Modus angezeigt, um ein schnelleres Laden zu ermöglichen.",
"edit-note": "Notiz bearbeiten"
},
"calendar_view": {
"delete_note": "Notiz löschen..."
},
"note-color": {
"clear-color": "Notizfarbe entfernen",
"set-color": "Notizfarbe wählen",
"set-custom-color": "Eigene Notizfarbe wählen"
}
}

View File

@ -14,11 +14,5 @@
"title": "Κρίσιμο σφάλμα",
"message": "Συνέβη κάποιο κρίσιμο σφάλμα, το οποίο δεν επιτρέπει στην εφαρμογή χρήστη να ξεκινήσει:\n\n{{message}}\n\nΤο πιθανότερο είναι να προκλήθηκε από κάποιο script που απέτυχε απρόοπτα. Δοκιμάστε να ξεκινήσετε την εφαρμογή σε ασφαλή λειτουργία για να λύσετε το πρόβλημα."
}
},
"ai_llm": {
"n_notes_queued": "{{ count }} σημείωση στην ουρά για εύρεση",
"n_notes_queued_plural": "{{ count }} σημειώσεις στην ουρά για εύρεση",
"notes_indexed": "{{ count }} σημείωση με ευρετήριο",
"notes_indexed_plural": "{{ count }} σημειώσεις με ευρετήριο"
}
}

View File

@ -0,0 +1,73 @@
{
"import": {
"safeImportTooltip": "Trilium <code>.zip</code> export files can contain executable scripts which may contain harmful behaviour. Safe import will deactivate automatic execution of all imported scripts. Uncheck \"Safe import\" only if the imported archive is supposed to contain executable scripts and you completely trust the contents of the import file.",
"shrinkImagesTooltip": "<p>If you check this option, Trilium will attempt to shrink the imported images by scaling and optimisation which may affect the perceived image quality. If unchecked, images will be imported without changes.</p><p>This doesn't apply to <code>.zip</code> imports with metadata since it is assumed these files are already optimised.</p>",
"codeImportedAsCode": "Import recognised code files (e.g. <code>.json</code>) as code notes if it's unclear from metadata"
},
"upload_attachments": {
"tooltip": "If you check this option, Trilium will attempt to shrink the uploaded images by scaling and optimisation which may affect the perceived image quality. If unchecked, images will be uploaded without changes."
},
"attribute_detail": {
"auto_read_only_disabled": "text/code notes can be set automatically into read mode when they are too large. You can disable this behaviour on per-note basis by adding this label to the note",
"workspace_tab_background_color": "CSS colour used in the note tab when hoisted to this note",
"color": "defines colour of the note in note tree, links etc. Use any valid CSS colour value like 'red' or #a13d5f",
"color_type": "Colour"
},
"mobile_detail_menu": {
"error_unrecognized_command": "Unrecognised command {{command}}"
},
"promoted_attributes": {
"remove_color": "Remove the colour label"
},
"max_content_width": {
"centerContent": "Keep content centred"
},
"theme": {
"auto_theme": "Legacy (Follow system colour scheme)",
"triliumnext": "Trilium (Follow system colour scheme)"
},
"search_engine": {
"custom_name_placeholder": "Customise search engine name",
"custom_url_placeholder": "Customise search engine url"
},
"highlights_list": {
"description": "You can customise the highlights list displayed in the right panel:",
"color": "Coloured text",
"bg_color": "Text with background colour"
},
"table_of_contents": {
"description": "Table of contents will appear in text notes when the note has more than a defined number of headings. You can customise this number:"
},
"custom_date_time_format": {
"description": "Customise the format of the date and time inserted via <shortcut /> or the toolbar. See <doc>Day.js docs</doc> for available format tokens."
},
"i18n": {
"title": "Localisation"
},
"attachment_detail_2": {
"unrecognized_role": "Unrecognised attachment role '{{role}}'."
},
"ai_llm": {
"reprocess_index_started": "Search index optimisation started in the background",
"index_rebuilding": "Optimising index ({{percentage}}%)",
"index_rebuild_complete": "Index optimisation complete"
},
"highlighting": {
"color-scheme": "Colour Scheme"
},
"code_theme": {
"color-scheme": "Colour scheme"
},
"call_to_action": {
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of colour to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer."
},
"settings_appearance": {
"related_code_blocks": "Colour scheme for code blocks in text notes",
"related_code_notes": "Colour scheme for code notes"
},
"note-color": {
"clear-color": "Clear note colour",
"set-color": "Set note colour",
"set-custom-color": "Set custom note colour"
}
}

View File

@ -112,6 +112,7 @@
},
"help": {
"title": "Cheatsheet",
"editShortcuts": "Edit keyboard shortcuts",
"noteNavigation": "Note navigation",
"goUpDown": "go up/down in the list of notes",
"collapseExpand": "collapse/expand node",
@ -204,7 +205,8 @@
"info": {
"modalTitle": "Info message",
"closeButton": "Close",
"okButton": "OK"
"okButton": "OK",
"copy_to_clipboard": "Copy to clipboard"
},
"jump_to_note": {
"search_placeholder": "Search for note by its name or type > for commands...",
@ -735,8 +737,8 @@
"zoom_out_title": "Zoom Out"
},
"zpetne_odkazy": {
"backlink": "{{count}} Backlink",
"backlinks": "{{count}} Backlinks",
"backlink_one": "{{count}} Backlink",
"backlink_other": "{{count}} Backlinks",
"relation": "relation"
},
"mobile_detail_menu": {
@ -764,9 +766,12 @@
"grid": "Grid",
"list": "List",
"collapse_all_notes": "Collapse all notes",
"expand_all_children": "Expand all children",
"expand_tooltip": "Expands the direct children of this collection (one level deep). For more options, press the arrow on the right.",
"collapse": "Collapse",
"expand": "Expand",
"expand_first_level": "Expand direct children",
"expand_nth_level": "Expand {{depth}} levels",
"expand_all_levels": "Expand all levels",
"book_properties": "Collection Properties",
"invalid_view_type": "Invalid view type '{{type}}'",
"calendar": "Calendar",
@ -983,7 +988,14 @@
"placeholder": "Type the content of your code note here..."
},
"editable_text": {
"placeholder": "Type the content of your note here..."
"placeholder": "Type the content of your note here...",
"editor_crashed_title": "The text editor crashed",
"editor_crashed_content": "Your content was recovered successfully, but a few of your most recent changes may not have been saved.",
"editor_crashed_details_button": "View more details...",
"editor_crashed_details_intro": "If you experience this error several times, consider reporting it on GitHub by pasting the information below.",
"editor_crashed_details_title": "Technical information",
"auto-detect-language": "Auto-detected",
"keeps-crashing": "Editing component keeps crashing. Please try restarting Trilium. If problem persists, consider creating a bug report."
},
"empty": {
"open_note_instruction": "Open a note by typing the note's title into the input below or choose a note in the tree.",
@ -1267,11 +1279,7 @@
"indexing_stopped": "Indexing stopped",
"indexing_in_progress": "Indexing in progress...",
"last_indexed": "Last Indexed",
"n_notes_queued": "{{ count }} note queued for indexing",
"n_notes_queued_plural": "{{ count }} notes queued for indexing",
"note_chat": "Note Chat",
"notes_indexed": "{{ count }} note indexed",
"notes_indexed_plural": "{{ count }} notes indexed",
"sources": "Sources",
"start_indexing": "Start Indexing",
"use_advanced_context": "Use Advanced Context",
@ -1309,7 +1317,10 @@
"title": "Editor"
},
"code_mime_types": {
"title": "Available MIME types in the dropdown"
"title": "Available MIME types in the dropdown",
"tooltip_syntax_highlighting": "Syntax highlighting",
"tooltip_code_block_syntax": "Code blocks in Text notes",
"tooltip_code_note_syntax": "Code notes"
},
"vim_key_bindings": {
"use_vim_keybindings_in_code_notes": "Vim keybindings",
@ -1630,7 +1641,7 @@
"import-into-note": "Import into note",
"apply-bulk-actions": "Apply bulk actions",
"converted-to-attachments": "{{count}} notes have been converted to attachments.",
"convert-to-attachment-confirm": "Are you sure you want to convert note selected notes into attachments of their parent notes?",
"convert-to-attachment-confirm": "Are you sure you want to convert the selected notes into attachments of their parent notes? This operation only applies to Image notes, other notes will be skipped.",
"open-in-popup": "Quick edit"
},
"shared_info": {
@ -1641,7 +1652,6 @@
"read-only-info": {
"read-only-note": "Currently viewing a read-only note.",
"auto-read-only-note": "This note is shown in a read-only mode for faster loading.",
"auto-read-only-learn-more": "Learn more",
"edit-note": "Edit note"
},
"note_types": {
@ -1721,7 +1731,8 @@
"refresh-saved-search-results": "Refresh saved search results",
"create-child-note": "Create child note",
"unhoist": "Unhoist",
"toggle-sidebar": "Toggle sidebar"
"toggle-sidebar": "Toggle sidebar",
"dropping-not-allowed": "Dropping notes into this location is not allowed."
},
"title_bar_buttons": {
"window-on-top": "Keep Window on Top"
@ -1823,9 +1834,6 @@
"move-to-available-launchers": "Move to available launchers",
"duplicate-launcher": "Duplicate launcher <kbd data-command=\"duplicateSubtree\">"
},
"editable-text": {
"auto-detect-language": "Auto-detected"
},
"highlighting": {
"title": "Code Blocks",
"description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.",
@ -1865,6 +1873,7 @@
"copy-link": "Copy link",
"paste": "Paste",
"paste-as-plain-text": "Paste as plain text",
"search_in_trilium": "Search for \"{{term}}\" in Trilium",
"search_online": "Search for \"{{term}}\" with {{searchEngine}}"
},
"image_context_menu": {
@ -1874,6 +1883,7 @@
"link_context_menu": {
"open_note_in_new_tab": "Open note in a new tab",
"open_note_in_new_split": "Open note in a new split",
"open_note_in_other_split": "Open note in the other split",
"open_note_in_new_window": "Open note in a new window",
"open_note_in_popup": "Quick edit"
},
@ -1962,7 +1972,8 @@
"button_title": "Export diagram as PNG"
},
"svg": {
"export_to_png": "The diagram could not be exported to PNG."
"export_to_png": "The diagram could not be exported to PNG.",
"export_to_svg": "The diagram could not be exported to SVG."
},
"code_theme": {
"title": "Appearance",
@ -2093,5 +2104,18 @@
},
"collections": {
"rendering_error": "Unable to show content due to an error."
},
"note-color": {
"clear-color": "Clear note color",
"set-color": "Set note color",
"set-custom-color": "Set custom note color"
},
"popup-editor": {
"maximize": "Switch to full editor"
},
"server": {
"unknown_http_error_title": "Communication error with the server",
"unknown_http_error_content": "Status code: {{statusCode}}\nURL: {{method}} {{url}}\nMessage: {{message}}",
"traefik_blocks_requests": "If you are using the Traefik reverse proxy, it introduced a breaking change which affects the communication with the server."
}
}

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