diff --git a/.editorconfig b/.editorconfig index cebb2ba580..e54c763817 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ root = true -[*.{js,ts,tsx}] +[*.{js,ts,tsx,css}] charset = utf-8 end_of_line = lf indent_size = 4 diff --git a/.github/actions/build-electron/action.yml b/.github/actions/build-electron/action.yml index 320fbd7635..9c6934a8d3 100644 --- a/.github/actions/build-electron/action.yml +++ b/.github/actions/build-electron/action.yml @@ -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 }} diff --git a/.github/actions/report-size/action.yml b/.github/actions/report-size/action.yml index 4be4fac5a0..f83206a696 100644 --- a/.github/actions/report-size/action.yml +++ b/.github/actions/report-size/action.yml @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..b4dfb29f7f --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 ` 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) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c523c2f1da..aff87f3494 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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` diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 5e8fb13015..25b44a8992 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -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 diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f9174fb428..041a31ea4a 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -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 diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index e66e79eaf6..e85239bccd 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cd30b44d0c..bde9a606b3 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 68e102a659..5739700977 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37fbe8c5df..eab78541e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 7a87cc1920..cb141375c0 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9ea55440e1..f986c67c54 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ upload .svelte-kit # docs -site/ \ No newline at end of file +site/ +apps/*/coverage diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4db7b7470f..c3af7fffbc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -9,7 +9,6 @@ "tobermory.es6-string-html", "vitest.explorer", "yzhang.markdown-all-in-one", - "svelte.svelte-vscode", - "bradlc.vscode-tailwindcss" + "usernamehw.errorlens" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 0c6d55c65b..2eaee6a3bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" } + ] } \ No newline at end of file diff --git a/_regroup/bin/create-anonymization-script.ts b/_regroup/bin/create-anonymization-script.ts deleted file mode 100644 index ff462ec5e2..0000000000 --- a/_regroup/bin/create-anonymization-script.ts +++ /dev/null @@ -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()); diff --git a/_regroup/bin/export-schema.sh b/_regroup/bin/export-schema.sh deleted file mode 100644 index ab5de1a815..0000000000 --- a/_regroup/bin/export-schema.sh +++ /dev/null @@ -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" \ No newline at end of file diff --git a/_regroup/bin/push-docker-image.sh b/_regroup/bin/push-docker-image.sh deleted file mode 100644 index 0372143cf1..0000000000 --- a/_regroup/bin/push-docker-image.sh +++ /dev/null @@ -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 diff --git a/_regroup/bin/release-flatpack.sh b/_regroup/bin/release-flatpack.sh deleted file mode 100644 index 31e42881b7..0000000000 --- a/_regroup/bin/release-flatpack.sh +++ /dev/null @@ -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 diff --git a/_regroup/bin/release.sh b/_regroup/bin/release.sh deleted file mode 100644 index fe9a65a36a..0000000000 --- a/_regroup/bin/release.sh +++ /dev/null @@ -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 diff --git a/_regroup/entitlements.plist b/_regroup/entitlements.plist deleted file mode 100644 index 040a4c1cb8..0000000000 --- a/_regroup/entitlements.plist +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.files.user-selected.read-write - - - \ No newline at end of file diff --git a/_regroup/eslint.config.js b/_regroup/eslint.config.js deleted file mode 100644 index 7c906beb2b..0000000000 --- a/_regroup/eslint.config.js +++ /dev/null @@ -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/*" - ] - } -); diff --git a/_regroup/eslint.format.config.js b/_regroup/eslint.format.config.js deleted file mode 100644 index 9dbfd78b2e..0000000000 --- a/_regroup/eslint.format.config.js +++ /dev/null @@ -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/*" - ] - } -]; diff --git a/_regroup/integration-tests/auth.setup.ts b/_regroup/integration-tests/auth.setup.ts deleted file mode 100644 index 9b31eec499..0000000000 --- a/_regroup/integration-tests/auth.setup.ts +++ /dev/null @@ -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 }); -}); diff --git a/_regroup/integration-tests/duplicate.spec.ts b/_regroup/integration-tests/duplicate.spec.ts deleted file mode 100644 index fe079952bd..0000000000 --- a/_regroup/integration-tests/duplicate.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/_regroup/integration-tests/example.disabled.ts b/_regroup/integration-tests/example.disabled.ts deleted file mode 100644 index a149fe3286..0000000000 --- a/_regroup/integration-tests/example.disabled.ts +++ /dev/null @@ -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(); -}); diff --git a/_regroup/integration-tests/settings.spec.ts b/_regroup/integration-tests/settings.spec.ts deleted file mode 100644 index b3fe16fda3..0000000000 --- a/_regroup/integration-tests/settings.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/_regroup/integration-tests/tree.spec.ts b/_regroup/integration-tests/tree.spec.ts deleted file mode 100644 index 257375fa81..0000000000 --- a/_regroup/integration-tests/tree.spec.ts +++ /dev/null @@ -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"); -}); diff --git a/_regroup/integration-tests/update_check.spec.ts b/_regroup/integration-tests/update_check.spec.ts deleted file mode 100644 index 38e28bf229..0000000000 --- a/_regroup/integration-tests/update_check.spec.ts +++ /dev/null @@ -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}`); -}); diff --git a/_regroup/package.json b/_regroup/package.json deleted file mode 100644 index 4f35064484..0000000000 --- a/_regroup/package.json +++ /dev/null @@ -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" - } -} diff --git a/_regroup/spec/etapi/app_info.ts b/_regroup/spec/etapi/app_info.ts deleted file mode 100644 index 9c510d99b2..0000000000 --- a/_regroup/spec/etapi/app_info.ts +++ /dev/null @@ -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"); - }); -}); -*/ diff --git a/_regroup/spec/etapi/backup.ts b/_regroup/spec/etapi/backup.ts deleted file mode 100644 index 924213f0e3..0000000000 --- a/_regroup/spec/etapi/backup.ts +++ /dev/null @@ -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); - }); -}); -*/ diff --git a/_regroup/spec/etapi/import.ts b/_regroup/spec/etapi/import.ts deleted file mode 100644 index 36782a26ab..0000000000 --- a/_regroup/spec/etapi/import.ts +++ /dev/null @@ -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"); - }); -}); -*/ diff --git a/_regroup/spec/etapi/notes.ts b/_regroup/spec/etapi/notes.ts deleted file mode 100644 index d5c9b680cd..0000000000 --- a/_regroup/spec/etapi/notes.ts +++ /dev/null @@ -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.`); - }); -}); -*/ diff --git a/_regroup/spec/support/etapi.ts b/_regroup/spec/support/etapi.ts deleted file mode 100644 index b32ba38e76..0000000000 --- a/_regroup/spec/support/etapi.ts +++ /dev/null @@ -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 { - return await fetch(`${HOST}/etapi/${url}`, { - method: "GET", - headers: { - Authorization: getEtapiAuthorizationHeader() - } - }); -} - -async function getEtapi(url: string): Promise { - const response = await getEtapiResponse(url); - return await processEtapiResponse(response); -} - -async function getEtapiContent(url: string): Promise { - const response = await fetch(`${HOST}/etapi/${url}`, { - method: "GET", - headers: { - Authorization: getEtapiAuthorizationHeader() - } - }); - - checkStatus(response); - - return response; -} - -async function postEtapi(url: string, data: Record = {}): Promise { - 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 { - 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 = {}): Promise { - 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 { - 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 = {}): Promise { - 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 { - const response = await fetch(`${HOST}/etapi/${url}`, { - method: "DELETE", - headers: { - Authorization: getEtapiAuthorizationHeader() - } - }); - return await processEtapiResponse(response); -} - -async function processEtapiResponse(response: Response): Promise { - 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 -}; diff --git a/_regroup/tsconfig.webpack.json b/_regroup/tsconfig.webpack.json deleted file mode 100644 index ed622818b3..0000000000 --- a/_regroup/tsconfig.webpack.json +++ /dev/null @@ -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" - ] -} diff --git a/apps/build-docs/package.json b/apps/build-docs/package.json index 3df797a597..8e3329b73e 100644 --- a/apps/build-docs/package.json +++ b/apps/build-docs/package.json @@ -9,14 +9,14 @@ "keywords": [], "author": "Elian Doran ", "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" } } diff --git a/apps/client/eslint.config.mjs b/apps/client/eslint.config.mjs deleted file mode 100644 index 724052a2e2..0000000000 --- a/apps/client/eslint.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import baseConfig from "../../eslint.config.mjs"; - -export default [ - ...baseConfig -]; diff --git a/apps/client/package.json b/apps/client/package.json index ba0fbb97b9..a1e8fb8990 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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" } diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index c73fe5a425..5cd4eecbe0 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -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: { diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 735978974a..b360c6fce1 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -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; } diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index 127ec30b7d..3cf06a7793 100644 --- a/apps/client/src/components/tab_manager.ts +++ b/apps/client/src/components/tab_manager.ts @@ -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 { diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index cca6c8c0f4..e77ba845b7 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -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"); diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 5fe9bc67d4..0f6df098a5 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -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; } diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 17c77f8d8c..50dc05d99e 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -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() .child() - .child(new SpacerWidget(0, 1)) + .child() .child() .child() .child() @@ -140,7 +140,7 @@ export default class DesktopLayout { .child() .child() ) - .child(new PromotedAttributesWidget()) + .child() .child() .child() .child() @@ -184,14 +184,14 @@ export default class DesktopLayout { launcherPane = new FlexContainer("row") .css("height", "53px") .class("horizontal") - .child(new LauncherContainer(true)) + .child() .child(); } else { launcherPane = new FlexContainer("column") .css("width", "53px") .class("vertical") .child() - .child(new LauncherContainer(false)) + .child() .child(); } diff --git a/apps/client/src/layouts/layout_commons.tsx b/apps/client/src/layouts/layout_commons.tsx index 031ef03de6..3620d495de 100644 --- a/apps/client/src/layouts/layout_commons.tsx +++ b/apps/client/src/layouts/layout_commons.tsx @@ -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() .child() .child() - .child(new PopupEditorDialog() - .child(new FlexContainer("row") - .class("title-row") - .css("align-items", "center") - .cssBlock(".title-row > * { margin: 5px; }") - .child() - .child()) - .child() - .child(new PromotedAttributesWidget()) - .child() - .child()) - .child(); + .child() + .child() + .child() } diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index c51ef9e92a..99c4600246 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -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 = ` `); 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) { diff --git a/apps/client/src/services/date_notes.ts b/apps/client/src/services/date_notes.ts index 51d8e68a2e..340ebf7f8c 100644 --- a/apps/client/src/services/date_notes.ts +++ b/apps/client/src/services/date_notes.ts @@ -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"; diff --git a/apps/client/src/services/debounce.ts b/apps/client/src/services/debounce.ts index 3ccfcd6e5a..4f16cc190d 100644 --- a/apps/client/src/services/debounce.ts +++ b/apps/client/src/services/debounce.ts @@ -12,7 +12,7 @@ * @param whether to execute at the beginning (`false`) * @api public */ -function debounce(func: (...args: unknown[]) => T, waitMs: number, immediate: boolean = false) { +function debounce(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; diff --git a/apps/client/src/services/dialog.ts b/apps/client/src/services/dialog.ts index 22efee3702..8711ec1751 100644 --- a/apps/client/src/services/dialog.ts +++ b/apps/client/src/services/dialog.ts @@ -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, closeActDialog = true, config?: Partial) { 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 })); } /** diff --git a/apps/client/src/services/froca-interface.ts b/apps/client/src/services/froca-interface.ts index 156bc684a2..80015cc343 100644 --- a/apps/client/src/services/froca-interface.ts +++ b/apps/client/src/services/froca-interface.ts @@ -13,7 +13,7 @@ export interface Froca { getBlob(entityType: string, entityId: string): Promise; getNote(noteId: string, silentNotFoundError?: boolean): Promise; - getNoteFromCache(noteId: string): FNote; + getNoteFromCache(noteId: string): FNote | undefined; getNotesFromCache(noteIds: string[], silentNotFoundError?: boolean): FNote[]; getNotes(noteIds: string[], silentNotFoundError?: boolean): Promise; diff --git a/apps/client/src/services/froca.ts b/apps/client/src/services/froca.ts index a1529db722..646db69d57 100644 --- a/apps/client/src/services/froca.ts +++ b/apps/client/src/services/froca.ts @@ -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"); } diff --git a/apps/client/src/services/frontend_script_api.ts b/apps/client/src/services/frontend_script_api.ts index 251edd1881..e16670d38d 100644 --- a/apps/client/src/services/frontend_script_api.ts +++ b/apps/client/src/services/frontend_script_api.ts @@ -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. + * + *

+ * 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; diff --git a/apps/client/src/services/i18n.ts b/apps/client/src/services/i18n.ts index 644767f9e5..5b5f38b762 100644 --- a/apps/client/src/services/i18n.ts +++ b/apps/client/src/services/i18n.ts @@ -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("options/locales"); @@ -27,6 +27,7 @@ export async function initLocale() { returnEmptyString: false }); + await setDayjsLocale(locale); translationsInitializedPromise.resolve(); } diff --git a/apps/client/src/services/import.ts b/apps/client/src/services/import.ts index 2300ca101f..80f0572823 100644 --- a/apps/client/src/services/import.ts +++ b/apps/client/src/services/import.ts @@ -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); diff --git a/apps/client/src/services/keyboard_actions.ts b/apps/client/src/services/keyboard_actions.ts index 5678d61962..d447a90019 100644 --- a/apps/client/src/services/keyboard_actions.ts +++ b/apps/client/src/services/keyboard_actions.ts @@ -28,7 +28,7 @@ async function getActionsForScope(scope: string) { return actions.filter((action) => action.scope === scope); } -async function setupActionsForElement(scope: string, $el: JQuery, component: Component) { +async function setupActionsForElement(scope: string, $el: JQuery, 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, 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); } diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 9af93b3135..a596e71366 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -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, diff --git a/apps/client/src/services/promoted_attribute_definition_parser.ts b/apps/client/src/services/promoted_attribute_definition_parser.ts index fe3a9cf2ea..0d93aae3c5 100644 --- a/apps/client/src/services/promoted_attribute_definition_parser.ts +++ b/apps/client/src/services/promoted_attribute_definition_parser.ts @@ -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 }; diff --git a/apps/client/src/services/protected_session.ts b/apps/client/src/services/protected_session.ts index 1e1984ae5d..395a844bb1 100644 --- a/apps/client/src/services/protected_session.ts +++ b/apps/client/src/services/protected_session.ts @@ -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); } diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index b2e6efb9a4..978aa91ab9 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -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}`); } } diff --git a/apps/client/src/services/shortcuts.ts b/apps/client/src/services/shortcuts.ts index 2c6345fcb2..167dc01d99 100644 --- a/apps/client/src/services/shortcuts.ts +++ b/apps/client/src/services/shortcuts.ts @@ -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; diff --git a/apps/client/src/services/toast.ts b/apps/client/src/services/toast.ts index 5325a06bca..3e81bf6e1b 100644 --- a/apps/client/src/services/toast.ts +++ b/apps/client/src/services/toast.ts @@ -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 - ? `\ -

` - : ` - ` - ); +export type ToastOptionsWithRequiredId = Omit & Required>; - $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([]); + +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) { + 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, diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index bb6d6d07fb..2c999690d4 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -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 | JQueryEventObject) { +export function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent | JQueryEventObject) { return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey); } @@ -207,7 +208,7 @@ function toObject(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(' 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((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 diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 4ce8b1cd77..f2f5546296 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -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; + } + } } \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-dark.css b/apps/client/src/stylesheets/theme-dark.css index a356d32fd0..690d49ecda 100644 --- a/apps/client/src/stylesheets/theme-dark.css +++ b/apps/client/src/stylesheets/theme-dark.css @@ -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); +} \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-light.css b/apps/client/src/stylesheets/theme-light.css index 872e7431fd..0c14a2d926 100644 --- a/apps/client/src/stylesheets/theme-light.css +++ b/apps/client/src/stylesheets/theme-light.css @@ -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); } \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-next-dark.css b/apps/client/src/stylesheets/theme-next-dark.css index 51db2bc27e..8358de09bf 100644 --- a/apps/client/src/stylesheets/theme-next-dark.css +++ b/apps/client/src/stylesheets/theme-next-dark.css @@ -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); } \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-next-light.css b/apps/client/src/stylesheets/theme-next-light.css index 66e3538c69..60e7d55b2b 100644 --- a/apps/client/src/stylesheets/theme-next-light.css +++ b/apps/client/src/stylesheets/theme-next-light.css @@ -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%); diff --git a/apps/client/src/stylesheets/theme-next/base.css b/apps/client/src/stylesheets/theme-next/base.css index 5976f1dfbe..2f207ed440 100644 --- a/apps/client/src/stylesheets/theme-next/base.css +++ b/apps/client/src/stylesheets/theme-next/base.css @@ -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 */ diff --git a/apps/client/src/stylesheets/theme-next/dialogs.css b/apps/client/src/stylesheets/theme-next/dialogs.css index ed617991c3..ffe6af9a5f 100644 --- a/apps/client/src/stylesheets/theme-next/dialogs.css +++ b/apps/client/src/stylesheets/theme-next/dialogs.css @@ -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); } diff --git a/apps/client/src/stylesheets/theme-next/forms.css b/apps/client/src/stylesheets/theme-next/forms.css index 31144eaa22..fb53f167d4 100644 --- a/apps/client/src/stylesheets/theme-next/forms.css +++ b/apps/client/src/stylesheets/theme-next/forms.css @@ -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 */ diff --git a/apps/client/src/stylesheets/theme-next/notes/text.css b/apps/client/src/stylesheets/theme-next/notes/text.css index 92574b5414..3636cb99d8 100644 --- a/apps/client/src/stylesheets/theme-next/notes/text.css +++ b/apps/client/src/stylesheets/theme-next/notes/text.css @@ -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; } diff --git a/apps/client/src/stylesheets/theme-next/pages.css b/apps/client/src/stylesheets/theme-next/pages.css index 3f5f1e223e..cf5bf0fe1b 100644 --- a/apps/client/src/stylesheets/theme-next/pages.css +++ b/apps/client/src/stylesheets/theme-next/pages.css @@ -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 */ diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index 2ae36db2a9..7643a02b24 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -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); diff --git a/apps/client/src/test/easy-froca.ts b/apps/client/src/test/easy-froca.ts index e6a9aeaff5..bcfa7eb0a5 100644 --- a/apps/client/src/test/easy-froca.ts +++ b/apps/client/src/test/easy-froca.ts @@ -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 }); } diff --git a/apps/client/src/test/setup.ts b/apps/client/src/test/setup.ts index 7f3f5aa1b9..f15c07567c 100644 --- a/apps/client/src/test/setup.ts +++ b/apps/client/src/test/setup.ts @@ -46,6 +46,8 @@ function mockServer() { attributes: [] } } + + console.warn(`Unsupported GET to mocked server: ${url}`); }, async post(url: string, data: object) { diff --git a/apps/client/src/translations/ar/translation.json b/apps/client/src/translations/ar/translation.json index 027431e6ad..81dc8ff89e 100644 --- a/apps/client/src/translations/ar/translation.json +++ b/apps/client/src/translations/ar/translation.json @@ -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": "تكرار المشغل " }, - "editable-text": { + "editable_text": { "auto-detect-language": "تم اكتشافه تلقائيا" }, "classic_editor_toolbar": { diff --git a/apps/client/src/translations/ca/translation.json b/apps/client/src/translations/ca/translation.json index d4cdcc1cac..2d79656827 100644 --- a/apps/client/src/translations/ca/translation.json +++ b/apps/client/src/translations/ca/translation.json @@ -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" + } } diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json index 8a6d195547..4c7e2d3e71 100644 --- a/apps/client/src/translations/cn/translation.json +++ b/apps/client/src/translations/cn/translation.json @@ -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": "复制启动器 " }, - "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 反向代理,它引入了一项影响与服务器的通信重大更改。" } } diff --git a/apps/client/src/translations/cs/translation.json b/apps/client/src/translations/cs/translation.json index 6ba0d690f0..e2bf0bd0da 100644 --- a/apps/client/src/translations/cs/translation.json +++ b/apps/client/src/translations/cs/translation.json @@ -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}}“" } } diff --git a/apps/client/src/translations/de/translation.json b/apps/client/src/translations/de/translation.json index 0dbfe34a91..afd2984786 100644 --- a/apps/client/src/translations/de/translation.json +++ b/apps/client/src/translations/de/translation.json @@ -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 " }, - "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" } } diff --git a/apps/client/src/translations/el/translation.json b/apps/client/src/translations/el/translation.json index 9de9eee040..cd1355575d 100644 --- a/apps/client/src/translations/el/translation.json +++ b/apps/client/src/translations/el/translation.json @@ -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 }} σημειώσεις με ευρετήριο" } } diff --git a/apps/client/src/translations/en-GB/translation.json b/apps/client/src/translations/en-GB/translation.json new file mode 100644 index 0000000000..4c2ed6bcef --- /dev/null +++ b/apps/client/src/translations/en-GB/translation.json @@ -0,0 +1,73 @@ +{ + "import": { + "safeImportTooltip": "Trilium .zip 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": "

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.

This doesn't apply to .zip imports with metadata since it is assumed these files are already optimised.

", + "codeImportedAsCode": "Import recognised code files (e.g. .json) 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 or the toolbar. See Day.js docs 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" + } +} diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 54025d6909..fea8ca13f9 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -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 " }, - "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." } } diff --git a/apps/client/src/translations/es/translation.json b/apps/client/src/translations/es/translation.json index 007ea176bb..9a289d7b89 100644 --- a/apps/client/src/translations/es/translation.json +++ b/apps/client/src/translations/es/translation.json @@ -690,7 +690,8 @@ "convert_into_attachment_failed": "La conversión de nota '{{title}}' falló.", "convert_into_attachment_successful": "La nota '{{title}}' ha sido convertida a un archivo adjunto.", "convert_into_attachment_prompt": "¿Está seguro que desea convertir la nota '{{title}}' en un archivo adjunto de la nota padre?", - "print_pdf": "Exportar como PDF..." + "print_pdf": "Exportar como PDF...", + "open_note_on_server": "Abrir nota en el servidor" }, "onclick_button": { "no_click_handler": "El widget de botón '{{componentId}}' no tiene un controlador de clics definido" @@ -734,9 +735,10 @@ "zoom_out_title": "Alejar" }, "zpetne_odkazy": { - "backlink": "{{count}} Vínculo de retroceso", - "backlinks": "{{count}} vínculos de retroceso", - "relation": "relación" + "relation": "relación", + "backlink_one": "{{count}} Vínculo de retroceso", + "backlink_many": "", + "backlink_other": "{{count}} vínculos de retroceso" }, "mobile_detail_menu": { "insert_child_note": "Insertar subnota", @@ -763,7 +765,6 @@ "grid": "Cuadrícula", "list": "Lista", "collapse_all_notes": "Contraer todas las notas", - "expand_all_children": "Ampliar todas las subnotas", "collapse": "Colapsar", "expand": "Expandir", "invalid_view_type": "Tipo de vista inválida '{{type}}'", @@ -773,7 +774,11 @@ "geo-map": "Mapa Geo", "board": "Tablero", "include_archived_notes": "Mostrar notas archivadas", - "presentation": "Presentación" + "presentation": "Presentación", + "expand_tooltip": "Expande las notas hijas inmediatas de esta colección (un nivel). Para más opciones, pulsa la flecha a la derecha.", + "expand_first_level": "Expandir hijos inmediatos", + "expand_nth_level": "Expandir {{depth}} niveles", + "expand_all_levels": "Expandir todos los niveles" }, "edited_notes": { "no_edited_notes_found": "Aún no hay notas editadas en este día...", @@ -982,7 +987,8 @@ "placeholder": "Escriba el contenido de su nota de código aquí..." }, "editable_text": { - "placeholder": "Escribe aquí el contenido de tu nota..." + "placeholder": "Escribe aquí el contenido de tu nota...", + "auto-detect-language": "Detectado automáticamente" }, "empty": { "open_note_instruction": "Abra una nota escribiendo el título de la nota en la entrada a continuación o elija una nota en el árbol.", @@ -1258,12 +1264,7 @@ "indexing_stopped": "Indexado detenido", "indexing_in_progress": "Indexado en progreso...", "last_indexed": "Último indexado", - "n_notes_queued_0": "{{ count }} nota agregada a la cola para indexar", - "n_notes_queued_1": "{{ count }} notas agregadas a la cola para indexar", - "n_notes_queued_2": "", "note_chat": "Chat de nota", - "notes_indexed": "{{ count }} nota indexada", - "notes_indexed_plural": "{{ count }} notas indexadas", "sources": "Fuentes", "start_indexing": "Comenzar indexado", "use_advanced_context": "Usar contexto avanzado", @@ -1301,7 +1302,10 @@ "title": "Editor" }, "code_mime_types": { - "title": "Tipos MIME disponibles en el menú desplegable" + "title": "Tipos MIME disponibles en el menú desplegable", + "tooltip_syntax_highlighting": "Resaltado de sintaxis", + "tooltip_code_block_syntax": "Bloques de código en notas de texto", + "tooltip_code_note_syntax": "Notas de código" }, "vim_key_bindings": { "use_vim_keybindings_in_code_notes": "Atajos de teclas de Vim", @@ -1809,9 +1813,6 @@ "move-to-available-launchers": "Mover a lanzadores disponibles", "duplicate-launcher": "Duplicar lanzador " }, - "editable-text": { - "auto-detect-language": "Detectado automáticamente" - }, "highlighting": { "title": "Bloques de código", "description": "Controla el resaltado de sintaxis para bloques de código dentro de las notas de texto, las notas de código no serán afectadas.", @@ -1851,7 +1852,8 @@ "copy-link": "Copiar enlace", "paste": "Pegar", "paste-as-plain-text": "Pegar como texto plano", - "search_online": "Buscar \"{{term}}\" con {{searchEngine}}" + "search_online": "Buscar \"{{term}}\" con {{searchEngine}}", + "search_in_trilium": "Buscar \"{{term}}\" en Trilium" }, "image_context_menu": { "copy_reference_to_clipboard": "Copiar referencia al portapapeles", @@ -1992,7 +1994,8 @@ "add-column-placeholder": "Ingresar título de la columna...", "edit-note-title": "Haga clic para editar el título de la nota", "edit-column-title": "Haga clic para editar el título de la columna", - "remove-from-board": "Eliminar del tablero" + "remove-from-board": "Eliminar del tablero", + "column-already-exists": "Esta columna ya existe en el tablero." }, "content_renderer": { "open_externally": "Abrir externamente" @@ -2087,10 +2090,14 @@ "read-only-info": { "read-only-note": "Actualmente, está viendo una nota de solo lectura.", "auto-read-only-note": "Esta nota se muestra en modo de solo lectura para una carga más rápida.", - "auto-read-only-learn-more": "Para saber más", "edit-note": "Editar nota" }, "calendar_view": { "delete_note": "Eliminar nota..." + }, + "note-color": { + "clear-color": "Borrar color de nota", + "set-color": "Asignar color de nota", + "set-custom-color": "Asignar color de nota personalizado" } } diff --git a/apps/client/src/translations/fr/translation.json b/apps/client/src/translations/fr/translation.json index fe9e28ba99..853758b519 100644 --- a/apps/client/src/translations/fr/translation.json +++ b/apps/client/src/translations/fr/translation.json @@ -39,7 +39,10 @@ "help_on_tree_prefix": "Aide sur le préfixe de l'arbre", "prefix": "Préfixe : ", "save": "Sauvegarder", - "branch_prefix_saved": "Le préfixe de la branche a été enregistré." + "branch_prefix_saved": "Le préfixe de la branche a été enregistré.", + "edit_branch_prefix_multiple": "Modifier le préfixe de branche pour {{count}} branches", + "branch_prefix_saved_multiple": "Le préfixe de la branche a été sauvegardé pour {{count}} branches.", + "affected_branches": "Branches impactées ({{count}}):" }, "bulk_actions": { "bulk_actions": "Actions groupées", @@ -730,9 +733,10 @@ "zoom_out_title": "Zoom arrière" }, "zpetne_odkazy": { - "backlink": "{{count}} Lien inverse", - "backlinks": "{{count}} Liens inverses", - "relation": "relation" + "relation": "relation", + "backlink_one": "{{count}} Lien inverse", + "backlink_many": "", + "backlink_other": "{{count}} Liens inverses" }, "mobile_detail_menu": { "insert_child_note": "Insérer une note enfant", @@ -759,7 +763,6 @@ "grid": "Grille", "list": "Liste", "collapse_all_notes": "Réduire toutes les notes", - "expand_all_children": "Développer tous les enfants", "collapse": "Réduire", "expand": "Développer", "invalid_view_type": "Type de vue non valide '{{type}}'", @@ -978,7 +981,8 @@ "placeholder": "Saisir le contenu de votre note de code ici..." }, "editable_text": { - "placeholder": "Saisir le contenu de votre note ici..." + "placeholder": "Saisir le contenu de votre note ici...", + "auto-detect-language": "Détecté automatiquement" }, "empty": { "open_note_instruction": "Ouvrez une note en tapant son titre dans la zone ci-dessous ou choisissez une note dans l'arborescence.", @@ -1614,9 +1618,6 @@ "move-to-available-launchers": "Déplacer vers les raccourcis disponibles", "duplicate-launcher": "Dupliquer le raccourci " }, - "editable-text": { - "auto-detect-language": "Détecté automatiquement" - }, "highlighting": { "description": "Contrôle la coloration syntaxique des blocs de code à l'intérieur des notes texte, les notes de code ne seront pas affectées.", "color-scheme": "Jeu de couleurs", @@ -1764,12 +1765,6 @@ "not_started": "Non démarré", "title": "Paramètres IA", "processed_notes": "Notes traitées", - "n_notes_queued_0": "{{ count }} note en attente d’indexation", - "n_notes_queued_1": "{{ count }} notes en attente d’indexation", - "n_notes_queued_2": "", - "notes_indexed_0": "{{ count }} note indexée", - "notes_indexed_1": "{{ count }} notes indexées", - "notes_indexed_2": "", "anthropic_url_description": "URL de base pour l'API Anthropic (par défaut : https ://api.anthropic.com)", "anthropic_model_description": "Modèles Anthropic Claude pour la complétion", "voyage_settings": "Réglages d'IA Voyage", diff --git a/apps/client/src/translations/it/translation.json b/apps/client/src/translations/it/translation.json index 0dd699a8cc..0806e6ee75 100644 --- a/apps/client/src/translations/it/translation.json +++ b/apps/client/src/translations/it/translation.json @@ -324,7 +324,8 @@ "copy-link": "Copia collegamento", "paste-as-plain-text": "Incolla come testo semplice", "add-term-to-dictionary": "Aggiungi \"{{term}}\" al dizionario", - "search_online": "Cerca \"{{term}}\" con {{searchEngine}}" + "search_online": "Cerca \"{{term}}\" con {{searchEngine}}", + "search_in_trilium": "Cerca \"{{term}}\" in Trilium" }, "editing": { "editor_type": { @@ -614,7 +615,8 @@ "showSQLConsole": "mostra console SQL", "other": "Altro", "quickSearch": "concentrati sull'input della ricerca rapida", - "inPageSearch": "ricerca all'interno della pagina" + "inPageSearch": "ricerca all'interno della pagina", + "editShortcuts": "Modifica scorciatoie da tastiera" }, "i18n": { "saturday": "Sabato", @@ -638,12 +640,6 @@ "friday": "Venerdì" }, "ai_llm": { - "n_notes_queued_0": "{{ count }} nota in coda per l'indicizzazione", - "n_notes_queued_1": "{{ count }} note in coda per l'indicizzazione", - "n_notes_queued_2": "{{ count }} note in coda per l'indicizzazione", - "notes_indexed_0": "{{ count }} nota indicizzata", - "notes_indexed_1": "{{ count }} note indicizzate", - "notes_indexed_2": "{{ count }} note indicizzate", "not_started": "Non iniziato", "title": "Impostazioni AI", "processed_notes": "Note elaborate", @@ -1308,9 +1304,10 @@ "zoom_out_title": "Rimpicciolisci" }, "zpetne_odkazy": { - "backlink": "{{count}} Backlink", - "backlinks": "{{count}} Backlinks", - "relation": "relazione" + "relation": "relazione", + "backlink_one": "{{count}} Backlink", + "backlink_many": "{{count}} Backlinks", + "backlink_other": "{{count}} Backlinks" }, "mobile_detail_menu": { "insert_child_note": "Inserisci nota secondaria", @@ -1337,7 +1334,6 @@ "grid": "Griglia", "list": "Lista", "collapse_all_notes": "Comprimi tutte le note", - "expand_all_children": "Espandi tutti i bambini", "collapse": "Crollo", "expand": "Espandere", "book_properties": "Proprietà della raccolta", @@ -1347,7 +1343,11 @@ "geo-map": "Mappa geografica", "board": "Asse", "presentation": "Presentazione", - "include_archived_notes": "Mostra note archiviate" + "include_archived_notes": "Mostra note archiviate", + "expand_tooltip": "Espande i figli diretti di questa raccolta (a un livello di profondità). Per ulteriori opzioni, premere la freccia a destra.", + "expand_first_level": "Espandi figli diretti", + "expand_nth_level": "Espandi {{depth}} livelli", + "expand_all_levels": "Espandi tutti i livelli" }, "edited_notes": { "no_edited_notes_found": "Nessuna nota modificata per questo giorno...", @@ -1491,7 +1491,9 @@ "placeholder": "Digita qui il contenuto della tua nota di codice..." }, "editable_text": { - "placeholder": "Digita qui il contenuto della tua nota..." + "placeholder": "Digita qui il contenuto della tua nota...", + "auto-detect-language": "Rilevato automaticamente", + "keeps-crashing": "Il componente di modifica continua a bloccarsi. Prova a riavviare Trilium. Se il problema persiste, valuta la possibilità di creare una segnalazione di bug." }, "empty": { "open_note_instruction": "Apri una nota digitandone il titolo nel campo sottostante oppure scegli una nota nell'albero.", @@ -1626,7 +1628,10 @@ "title": "Redattore" }, "code_mime_types": { - "title": "Tipi MIME disponibili nel menu a discesa" + "title": "Tipi MIME disponibili nel menu a discesa", + "tooltip_syntax_highlighting": "Evidenziazione della sintassi", + "tooltip_code_block_syntax": "Blocchi di codice nelle note di testo", + "tooltip_code_note_syntax": "Note sul codice" }, "vim_key_bindings": { "use_vim_keybindings_in_code_notes": "Combinazioni di tasti di Vim", @@ -1798,8 +1803,8 @@ "relation-map": "Mappa delle relazioni", "note-map": "Nota Mappa", "render-note": "Nota di rendering", - "book": "Collezione", - "mermaid-diagram": "Diagramma della sirena", + "book": "Raccolta", + "mermaid-diagram": "Diagramma Mermaid", "canvas": "Tela", "web-view": "Visualizzazione Web", "mind-map": "Mappa mentale", @@ -1850,7 +1855,8 @@ "refresh-saved-search-results": "Aggiorna i risultati della ricerca salvati", "create-child-note": "Crea nota figlio", "unhoist": "Sganciare", - "toggle-sidebar": "Attiva/disattiva la barra laterale" + "toggle-sidebar": "Attiva/disattiva la barra laterale", + "dropping-not-allowed": "Non è consentito lasciare appunti in questa posizione." }, "title_bar_buttons": { "window-on-top": "Mantieni la finestra in primo piano" @@ -1933,9 +1939,6 @@ "move-to-available-launchers": "Passa ai launcher disponibili", "duplicate-launcher": "Duplica il launcher " }, - "editable-text": { - "auto-detect-language": "Rilevato automaticamente" - }, "highlighting": { "title": "Blocchi di codice", "description": "Controlla l'evidenziazione della sintassi per i blocchi di codice all'interno delle note di testo; le note di codice non saranno interessate.", @@ -1962,7 +1965,8 @@ "open_note_in_new_tab": "Apri la nota in una nuova scheda", "open_note_in_new_split": "Apri nota in una nuova divisione", "open_note_in_new_window": "Apri la nota in una nuova finestra", - "open_note_in_popup": "Modifica rapida" + "open_note_in_popup": "Modifica rapida", + "open_note_in_other_split": "Apri nota nell'altra divisione" }, "help-button": { "title": "Apri la pagina di aiuto pertinente" @@ -2090,10 +2094,17 @@ "read-only-info": { "read-only-note": "Stai visualizzando una nota di sola lettura.", "auto-read-only-note": "Questa nota viene visualizzata in modalità di sola lettura per un caricamento più rapido.", - "auto-read-only-learn-more": "Per saperne di più", "edit-note": "Modifica nota" }, "calendar_view": { "delete_note": "Eliminazione nota..." + }, + "note-color": { + "set-color": "Imposta colore nota", + "set-custom-color": "Imposta colore personalizzato per le note", + "clear-color": "Pulisci colore della nota" + }, + "popup-editor": { + "maximize": "Passa all'editor completo" } } diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json index c33a3f8258..539a79da9c 100644 --- a/apps/client/src/translations/ja/translation.json +++ b/apps/client/src/translations/ja/translation.json @@ -312,7 +312,8 @@ "moveNoteUpDown": "ノートリストでノートを上/下に移動", "notSet": "未設定", "goUpDown": "ノートのリストで上下する", - "editBranchPrefix": "アクティブノートのクローンの プレフィックス を編集する" + "editBranchPrefix": "アクティブノートのクローンの プレフィックス を編集する", + "editShortcuts": "キーボードショートカットを編集" }, "import": { "importIntoNote": "ノートにインポート", @@ -420,7 +421,7 @@ "apply-bulk-actions": "一括操作の適用", "converted-to-attachments": "{{count}}ノートが添付ファイルに変換されました。", "convert-to-attachment": "添付ファイルに変換", - "convert-to-attachment-confirm": "選択したノートを親ノートの添付ファイルに変換しますか?", + "convert-to-attachment-confirm": "選択したノートを親ノートの添付ファイルに変換してもよろしいですか?この操作は画像ノートにのみ適用され、その他のノートはスキップされます。", "open-in-popup": "クイック編集", "hoist-note": "ホイストノート", "unhoist-note": "ノートをホイストしない", @@ -496,7 +497,8 @@ "new-item-placeholder": "ノートのタイトルを入力...", "add-column-placeholder": "列名を入力...", "edit-note-title": "クリックしてノートのタイトルを編集", - "edit-column-title": "クリックして列のタイトルを編集" + "edit-column-title": "クリックして列のタイトルを編集", + "column-already-exists": "この列は既にボード上に存在します。" }, "code_buttons": { "execute_button_title": "スクリプトを実行", @@ -530,7 +532,6 @@ "grid": "グリッド", "list": "リスト", "collapse_all_notes": "すべてのノートを折りたたむ", - "expand_all_children": "すべての子を展開", "collapse": "折りたたむ", "expand": "展開", "book_properties": "コレクションプロパティ", @@ -541,7 +542,11 @@ "geo-map": "ジオマップ", "board": "ボード", "include_archived_notes": "アーカイブされたノートを表示", - "presentation": "プレゼンテーション" + "presentation": "プレゼンテーション", + "expand_tooltip": "このコレクションの直下の子(1階層下)を展開します。その他のオプションについては、右側の矢印を押してください。", + "expand_first_level": "直下の子を展開", + "expand_nth_level": "{{depth}} 階層下まで展開", + "expand_all_levels": "すべての階層を展開" }, "note_types": { "geo-map": "ジオマップ", @@ -1153,7 +1158,8 @@ "open_note_in_popup": "クイック編集", "open_note_in_new_tab": "新しいタブでノートを開く", "open_note_in_new_split": "新しく分割してノートを開く", - "open_note_in_new_window": "新しいウィンドウでノートを開く" + "open_note_in_new_window": "新しいウィンドウでノートを開く", + "open_note_in_other_split": "他の分割画面でノートを開く" }, "note_tooltip": { "quick-edit": "クイック編集", @@ -1208,7 +1214,8 @@ "unhoist": "ホイスト解除", "saved-search-note-refreshed": "保存した検索ノートが更新されました。", "refresh-saved-search-results": "保存した検索結果を更新", - "toggle-sidebar": "サイドバーを切り替え" + "toggle-sidebar": "サイドバーを切り替え", + "dropping-not-allowed": "この場所にノートをドロップすることはできません。" }, "bulk_actions": { "bulk_actions": "一括操作", @@ -1261,9 +1268,6 @@ "duplicate-launcher": "ランチャーの複製 ", "reset_launcher_confirm": "本当に「{{title}}」をリセットしますか? このノート(およびその子ノート)のすべてのデータと設定が失われ、ランチャーは元の場所に戻ります。" }, - "editable-text": { - "auto-detect-language": "自動検出" - }, "highlighting": { "title": "コードブロック", "description": "テキストノート内のコードブロックのシンタックスハイライトを制御します。コードノートには影響しません。", @@ -1300,7 +1304,8 @@ "copy-link": "リンクをコピー", "paste": "貼り付け", "paste-as-plain-text": "プレーンテキストで貼り付け", - "search_online": "{{searchEngine}} で \"{{term}}\" を検索" + "search_online": "{{searchEngine}} で \"{{term}}\" を検索", + "search_in_trilium": "Triliumで「{{term}}」を検索" }, "duration": { "seconds": "秒", @@ -1499,9 +1504,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": "高度なコンテキストを使用", @@ -1582,9 +1585,8 @@ "this_launcher_doesnt_define_target_note": "このランチャーはターゲットノートを定義していません。" }, "zpetne_odkazy": { - "backlink": "{{count}} バックリンク", - "backlinks": "{{count}} バックリンク", - "relation": "リレーション" + "relation": "リレーション", + "backlink_other": "{{count}} 個のバックリンク" }, "mobile_detail_menu": { "delete_this_note": "このノートを削除", @@ -1772,7 +1774,9 @@ "placeholder": "ここにコードノートの内容を入力..." }, "editable_text": { - "placeholder": "ここにノートの内容を入力..." + "placeholder": "ここにノートの内容を入力...", + "auto-detect-language": "自動検出", + "keeps-crashing": "編集コンポーネントがクラッシュし続けます。Trilium を再起動してください。問題が解決しない場合は、バグレポートの作成をご検討ください。" }, "empty": { "open_note_instruction": "以下の入力欄にノートのタイトルを入力するか、ツリー内のノートを選択してノートを開きます。", @@ -1826,7 +1830,10 @@ "app-restart-required": "(変更を有効にするにはアプリケーションの再起動が必要です)" }, "code_mime_types": { - "title": "ドロップダウンで利用可能なMIMEタイプ" + "title": "ドロップダウンで利用可能なMIMEタイプ", + "tooltip_syntax_highlighting": "構文ハイライト表示", + "tooltip_code_block_syntax": "テキストノート内のコードブロック", + "tooltip_code_note_syntax": "コードノート" }, "attachment_erasure_timeout": { "attachment_erasure_timeout": "添付ファイル消去のタイムアウト", @@ -1929,7 +1936,7 @@ "search-for": "「{{term}}」を検索", "create-note": "子ノート「{{term}}」を作成してリンクする", "insert-external-link": "「{{term}}」への外部リンクを挿入", - "clear-text-field": "テキストフィールドを消去", + "clear-text-field": "テキストフィールドをクリア", "show-recent-notes": "最近のノートを表示", "full-text-search": "全文検索" }, @@ -2088,7 +2095,14 @@ "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": "フルエディターに切り替え" } } diff --git a/apps/client/src/translations/ko/translation.json b/apps/client/src/translations/ko/translation.json index 6cfda19246..2f81cbe413 100644 --- a/apps/client/src/translations/ko/translation.json +++ b/apps/client/src/translations/ko/translation.json @@ -39,7 +39,9 @@ "edit_branch_prefix": "브랜치 접두사 편집", "help_on_tree_prefix": "트리 접두사에 대한 도움말", "prefix": "접두사: ", - "branch_prefix_saved": "브랜치 접두사가 저장되었습니다." + "branch_prefix_saved": "브랜치 접두사가 저장되었습니다.", + "edit_branch_prefix_multiple": "{{count}}개의 지점 접두사 편집", + "branch_prefix_saved_multiple": "{{count}}개의 지점에 대해 지점 접두사가 저장되었습니다." }, "bulk_actions": { "bulk_actions": "대량 작업", diff --git a/apps/client/src/translations/nl/translation.json b/apps/client/src/translations/nl/translation.json index de5cfb6c7b..e38c402804 100644 --- a/apps/client/src/translations/nl/translation.json +++ b/apps/client/src/translations/nl/translation.json @@ -16,10 +16,12 @@ }, "widget-error": { "title": "Starten widget mislukt", - "message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}" + "message-unknown": "Onbekende widget kan niet gestart worden omdat:\n\n{{message}}", + "message-custom": "Aangepaste widget van notitie met ID \"{{id}}\", getiteld \"{{title}}\" kon niet worden geïnitialiseerd vanwege:\n\n{{message}}" }, "bundle-error": { - "title": "Custom script laden mislukt" + "title": "Custom script laden mislukt", + "message": "Script van notitie met ID \"{{id}}\", getiteld \"{{title}}\" kon niet worden uitgevoerd vanwege:\n\n{{message}}" } }, "add_link": { @@ -29,14 +31,17 @@ "search_note": "zoek voor notitie op naam", "link_title_mirrors": "De link titel is hetzelfde als de notitie's huidige titel", "link_title": "Link titel", - "button_add_link": "Link toevoegen" + "button_add_link": "Link toevoegen", + "link_title_arbitrary": "snelkoppelingsnaam kan willekeurig worden aangepast" }, "branch_prefix": { "edit_branch_prefix": "Bewerk branch prefix", "save": "Opslaan", "branch_prefix_saved": "Branch prefix is opgeslagen.", "help_on_tree_prefix": "Help bij boomvoorvoegsel", - "prefix": "Voorvoegsel: " + "prefix": "Voorvoegsel: ", + "edit_branch_prefix_multiple": "Bewerk zijtakvoorvoegsel voor {{count}} zijtakken", + "branch_prefix_saved_multiple": "Vertakkingsvoorvoegsel opgeslagen voor {{count}} vertakkingen." }, "bulk_actions": { "bulk_actions": "Bulk acties", diff --git a/apps/client/src/translations/pl/translation.json b/apps/client/src/translations/pl/translation.json index efaf425b32..6f3ed86c0c 100644 --- a/apps/client/src/translations/pl/translation.json +++ b/apps/client/src/translations/pl/translation.json @@ -165,7 +165,6 @@ "view_type": "Typ widoku", "grid": "Siatka", "collapse_all_notes": "Zwiń wszystkie notatki", - "expand_all_children": "Rozwiń wszystkie dzieci", "collapse": "Zwiń", "expand": "Rozwiń", "book_properties": "Właściwości kolekcji", @@ -751,9 +750,6 @@ "indexing_stopped": "Indeksowanie zatrzymane", "indexing_in_progress": "Indeksowanie w trakcie...", "last_indexed": "Ostatnio zindeksowane", - "n_notes_queued_0": "{{ count }} notatka zakolejkowana do indeksowania", - "n_notes_queued_1": "{{ count }} notatek zakolejkowanych do indeksowania", - "n_notes_queued_2": "{{ count }} notatek zakolejkowanych do indeksowania", "note_chat": "Czat notatki", "note_title": "Tytuł notatki", "error": "Błąd", @@ -861,9 +857,6 @@ "enter_message": "Wpisz swoją wiadomość...", "error_contacting_provider": "Błąd kontaktu z dostawcą AI. Sprawdź ustawienia i połączenie internetowe.", "error_generating_response": "Błąd generowania odpowiedzi AI", - "notes_indexed_0": "{{ count }} notatka zaindeksowana", - "notes_indexed_1": "{{ count }} notatek zaindeksowanych", - "notes_indexed_2": "", "sources": "Źródła", "start_indexing": "Rozpocznij indeksowanie", "use_advanced_context": "Użyj zaawansowanego kontekstu", @@ -1238,9 +1231,10 @@ "zoom_out_title": "Pomniejsz" }, "zpetne_odkazy": { - "backlink": "{{count}} Backlink", - "backlinks": "{{count}} Backlinków", - "relation": "relacja" + "relation": "relacja", + "backlink_one": "{{count}} Backlink", + "backlink_few": "", + "backlink_many": "{{count}} Backlinków" }, "mobile_detail_menu": { "insert_child_note": "Wstaw notatkę podrzędną", @@ -1336,7 +1330,8 @@ "placeholder": "Wpisz tutaj treść swojej notatki kodowej..." }, "editable_text": { - "placeholder": "Wpisz tutaj treść swojej notatki..." + "placeholder": "Wpisz tutaj treść swojej notatki...", + "auto-detect-language": "Wykryto automatycznie" }, "empty": { "open_note_instruction": "Otwórz notatkę, wpisując jej tytuł w poniższe pole lub wybierz notatkę z drzewa.", @@ -2020,9 +2015,6 @@ "move-to-available-launchers": "Przenieś do dostępnych programów uruchamiających", "duplicate-launcher": "Duplikuj program uruchamiający " }, - "editable-text": { - "auto-detect-language": "Wykryto automatycznie" - }, "highlighting": { "title": "Bloki kodu", "description": "Kontroluje podświetlanie składni dla bloków kodu w notatkach tekstowych, notatki kodowe nie będą miały wpływu.", diff --git a/apps/client/src/translations/pt/translation.json b/apps/client/src/translations/pt/translation.json index ba65215454..6dfd0eeb6f 100644 --- a/apps/client/src/translations/pt/translation.json +++ b/apps/client/src/translations/pt/translation.json @@ -711,9 +711,10 @@ "zoom_out_title": "Reduzir" }, "zpetne_odkazy": { - "backlink": "{{count}} Ligação Reversa", - "backlinks": "{{count}} Ligações Reversas", - "relation": "relação" + "relation": "relação", + "backlink_one": "{{count}} Ligação Reversa", + "backlink_many": "", + "backlink_other": "{{count}} Ligações Reversas" }, "mobile_detail_menu": { "insert_child_note": "Inserir nota filha", @@ -739,7 +740,6 @@ "grid": "Grade", "list": "Lista", "collapse_all_notes": "Recolher todas as notas", - "expand_all_children": "Expandir todos os filhos", "collapse": "Recolher", "expand": "Expandir", "book_properties": "Propriedades da Coleção", @@ -954,7 +954,8 @@ "placeholder": "Digite o conteúdo da sua nota de código aqui…" }, "editable_text": { - "placeholder": "Digite o conteúdo da sua nota aqui…" + "placeholder": "Digite o conteúdo da sua nota aqui…", + "auto-detect-language": "Detetado automaticamente" }, "empty": { "open_note_instruction": "Abra uma nota a digitar o título da nota no campo abaixo ou escolha uma nota na árvore.", @@ -1235,13 +1236,7 @@ "indexing_stopped": "Indexação interrompida", "indexing_in_progress": "Indexação em andamento…", "last_indexed": "Última Indexada", - "n_notes_queued_0": "{{ count }} nota enfileirada para indexação", - "n_notes_queued_1": "{{ count }} notas enfileiradas para indexação", - "n_notes_queued_2": "{{ count }} notas enfileiradas para indexação", "note_chat": "Conversa de Nota", - "notes_indexed_0": "{{ count }} nota indexada", - "notes_indexed_1": "{{ count }} notas indexadas", - "notes_indexed_2": "{{ count }} notas indexadas", "sources": "Origens", "start_indexing": "Iniciar Indexação", "use_advanced_context": "Usar Contexto Avançado", @@ -1774,9 +1769,6 @@ "move-to-available-launchers": "Mover para lançadores disponíveis", "duplicate-launcher": "Duplicar o lançador " }, - "editable-text": { - "auto-detect-language": "Detetado automaticamente" - }, "highlighting": { "title": "Blocos de Código", "description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.", diff --git a/apps/client/src/translations/pt_br/translation.json b/apps/client/src/translations/pt_br/translation.json index e8612e1bf2..6a875cee98 100644 --- a/apps/client/src/translations/pt_br/translation.json +++ b/apps/client/src/translations/pt_br/translation.json @@ -75,12 +75,6 @@ "note_cloned": "A nota \"{{clonedTitle}}\" foi clonada para \"{{targetTitle}}\"" }, "ai_llm": { - "n_notes_queued_0": "{{ count }} nota enfileirada para indexação", - "n_notes_queued_1": "{{ count }} notas enfileiradas para indexação", - "n_notes_queued_2": "{{ count }} notas enfileiradas para indexação", - "notes_indexed_0": "{{ count }} nota indexada", - "notes_indexed_1": "{{ count }} notas indexadas", - "notes_indexed_2": "{{ count }} notas indexadas", "temperature": "Temperatura", "retry_queued": "Nota enfileirada para nova tentativa", "queued_notes": "Notas Enfileiradas", @@ -976,9 +970,10 @@ "reset_pan_zoom_title": "Redefinir pan & zoom para coordenadas e ampliação iniciais" }, "zpetne_odkazy": { - "backlink": "{{count}} Links Reversos", - "backlinks": "{{count}} Links Reversos", - "relation": "relação" + "relation": "relação", + "backlink_one": "", + "backlink_many": "", + "backlink_other": "{{count}} Links Reversos" }, "mobile_detail_menu": { "insert_child_note": "Inserir nota filha", @@ -1004,7 +999,6 @@ "grid": "Grade", "list": "Lista", "collapse_all_notes": "Recolher todas as notas", - "expand_all_children": "Expandir todos os filhos", "collapse": "Recolher", "expand": "Expandir", "book_properties": "Propriedades da Coleção", @@ -1197,7 +1191,8 @@ "placeholder": "Digite o conteúdo da sua nota de código aqui…" }, "editable_text": { - "placeholder": "Digite o conteúdo da sua nota aqui…" + "placeholder": "Digite o conteúdo da sua nota aqui…", + "auto-detect-language": "Detectado automaticamente" }, "empty": { "search_placeholder": "buscar uma nota pelo nome", @@ -1695,9 +1690,6 @@ "move-to-available-launchers": "Mover para lançadores disponíveis", "duplicate-launcher": "Duplicar o lançador " }, - "editable-text": { - "auto-detect-language": "Detectado automaticamente" - }, "highlighting": { "title": "Blocos de Código", "description": "Controla o destaque de sintaxe para blocos de código dentro de notas de texto, notas de código não serão afetadas.", diff --git a/apps/client/src/translations/ro/translation.json b/apps/client/src/translations/ro/translation.json index b0e412b35c..4fba198908 100644 --- a/apps/client/src/translations/ro/translation.json +++ b/apps/client/src/translations/ro/translation.json @@ -279,7 +279,6 @@ "collapse": "Minimizează", "collapse_all_notes": "Minimizează toate notițele", "expand": "Expandează", - "expand_all_children": "Expandează toate subnotițele", "grid": "Grilă", "invalid_view_type": "Mod de afișare incorect „{{type}}”", "list": "Listă", @@ -290,7 +289,11 @@ "geo-map": "Hartă geografică", "board": "Tablă Kanban", "include_archived_notes": "Afișează notițele arhivate", - "presentation": "Prezentare" + "presentation": "Prezentare", + "expand_tooltip": "Expandează subnotițele directe ale acestei colecții (un singur nivel de adâncime). Pentru mai multe opțiuni, apăsați săgeata din dreapta.", + "expand_first_level": "Expandează subnotițele directe", + "expand_nth_level": "Expandează pe {{depth}} nivele", + "expand_all_levels": "Expandează pe toate nivelele" }, "bookmark_switch": { "bookmark": "Semn de carte", @@ -384,7 +387,10 @@ "trilium_api_docs_button_title": "Deschide documentația API pentru Trilium" }, "code_mime_types": { - "title": "Tipuri MIME disponibile în meniul derulant" + "title": "Tipuri MIME disponibile în meniul derulant", + "tooltip_syntax_highlighting": "Evidențiere de sintaxă", + "tooltip_code_block_syntax": "Blocuri de cod în notițe text", + "tooltip_code_note_syntax": "Notițe de tip cod" }, "confirm": { "also_delete_note": "Șterge și notița", @@ -485,7 +491,9 @@ "placeholder": "Scrieți conținutul notiței de cod aici..." }, "editable_text": { - "placeholder": "Scrieți conținutul notiței aici..." + "placeholder": "Scrieți conținutul notiței aici...", + "auto-detect-language": "Automat", + "keeps-crashing": "Componenta de editare se blochează în continuu. Încercați să reporniți Trilium. Dacă problema persistă, luați în considerare să raportați această problemă." }, "edited_notes": { "deleted": "(șters)", @@ -674,7 +682,8 @@ "tabShortcuts": "Scurtături pentru tab-uri", "troubleshooting": "Unelte pentru depanare", "newTabWithActivationNoteLink": "pe o legătură către o notiță deschide și activează notița într-un tab nou", - "title": "Ghid rapid" + "title": "Ghid rapid", + "editShortcuts": "Editează scurtăturile de la tastatură" }, "hide_floating_buttons_button": { "button_title": "Ascunde butoanele" @@ -1356,8 +1365,9 @@ "title": "Factorul de zoom (doar pentru versiunea desktop)" }, "zpetne_odkazy": { - "backlink": "{{count}} legături de retur", - "backlinks": "{{count}} legături de retur", + "backlink_one": "{{count}} legătură de retur", + "backlink_few": "{{count}} legături de retur", + "backlink_other": "{{count}} de legături de retur", "relation": "relație" }, "svg_export_button": { @@ -1504,7 +1514,8 @@ "hoist-this-note-workspace": "Focalizează spațiul de lucru", "refresh-saved-search-results": "Reîmprospătează căutarea salvată", "unhoist": "Defocalizează notița", - "toggle-sidebar": "Comută bara laterală" + "toggle-sidebar": "Comută bara laterală", + "dropping-not-allowed": "Aici nu este permisă plasarea notițelor." }, "title_bar_buttons": { "window-on-top": "Menține fereastra mereu vizibilă" @@ -1617,9 +1628,6 @@ "move-to-visible-launchers": "Mută în Lansatoare vizibile", "reset": "Resetează" }, - "editable-text": { - "auto-detect-language": "Automat" - }, "highlighting": { "color-scheme": "Temă de culori", "description": "Controlează evidențierea de sintaxă pentru blocurile de cod în interiorul notițelor text, notițele de tip cod nu vor fi afectate de aceste setări.", @@ -1659,7 +1667,8 @@ "cut": "Decupează", "paste": "Lipește", "paste-as-plain-text": "Lipește doar textul", - "search_online": "Caută „{{term}}” cu {{searchEngine}}" + "search_online": "Caută „{{term}}” cu {{searchEngine}}", + "search_in_trilium": "Caută „{{term}}” în Trilium" }, "image_context_menu": { "copy_image_to_clipboard": "Copiază imaginea în clipboard", @@ -1669,7 +1678,8 @@ "open_note_in_new_split": "Deschide notița într-un panou nou", "open_note_in_new_tab": "Deschide notița într-un tab nou", "open_note_in_new_window": "Deschide notița într-o fereastră nouă", - "open_note_in_popup": "Editare rapidă" + "open_note_in_popup": "Editare rapidă", + "open_note_in_other_split": "Deschide notița în celălalt panou" }, "note_autocomplete": { "clear-text-field": "Șterge conținutul casetei", @@ -1880,13 +1890,7 @@ "indexing_stopped": "Indexarea s-a oprit", "indexing_in_progress": "Indexare în curs...", "last_indexed": "Ultima indexare", - "n_notes_queued_0": "O notiță adăugată în coada de indexare", - "n_notes_queued_1": "{{ count }} notițe adăugate în coada de indexare", - "n_notes_queued_2": "{{ count }} de notițe adăugate în coada de indexare", "note_chat": "Discuție pe baza notițelor", - "notes_indexed_0": "O notiță indexată", - "notes_indexed_1": "{{ count }} notițe indexate", - "notes_indexed_2": "{{ count }} de notițe indexate", "sources": "Surse", "start_indexing": "Indexează", "use_advanced_context": "Folosește context îmbogățit", @@ -2090,10 +2094,17 @@ "read-only-info": { "read-only-note": "Vizualizați o notiță în modul doar în citire.", "auto-read-only-note": "Această notiță este afișată în modul doar în citire din motive de performanță.", - "auto-read-only-learn-more": "Mai multe detalii", "edit-note": "Editează notița" }, "calendar_view": { "delete_note": "Șterge notița..." + }, + "note-color": { + "clear-color": "Înlăturați culoarea notiței", + "set-color": "Setați culoarea notiței", + "set-custom-color": "Setați culoare personalizată pentru notiță" + }, + "popup-editor": { + "maximize": "Comută la editorul principal" } } diff --git a/apps/client/src/translations/ru/translation.json b/apps/client/src/translations/ru/translation.json index 7301c56a60..a6b21b5aa9 100644 --- a/apps/client/src/translations/ru/translation.json +++ b/apps/client/src/translations/ru/translation.json @@ -726,9 +726,6 @@ "title": "Блоки кода", "description": "Управляет подсветкой синтаксиса для блоков кода внутри текстовых заметок. Заметки с типом \"Код\" не будут затронуты." }, - "editable-text": { - "auto-detect-language": "Определен автоматически" - }, "launcher_context_menu": { "reset": "Сбросить", "add-spacer": "Добавить разделитель", @@ -984,9 +981,10 @@ "new-version-available": "Доступно обновление" }, "zpetne_odkazy": { - "backlink": "{{count}} ссылки", - "backlinks": "{{count}} ссылок", - "relation": "отношение" + "relation": "отношение", + "backlink_one": "{{count}} ссылки", + "backlink_few": "", + "backlink_many": "{{count}} ссылок" }, "note_icon": { "category": "Категория:", @@ -1013,7 +1011,6 @@ "book_properties": "Свойства коллекции", "geo-map": "Карта", "invalid_view_type": "Недопустимый тип представления '{{type}}'", - "expand_all_children": "Развернуть все дочерние элементы", "collapse_all_notes": "Свернуть все заметки", "include_archived_notes": "Показать заархивированные заметки" }, @@ -1335,16 +1332,10 @@ "error_fetching": "Ошибка получения списка моделей: {{error}}", "index_rebuild_status_error": "Ошибка проверки статуса перестроения индекса", "enhanced_context_description": "Предоставляет ИИ больше контекста из заметки и связанных с ней заметок для более точных ответов", - "n_notes_queued_0": "{{ count }} заметка в очереди на индексирование", - "n_notes_queued_1": "{{ count }} заметки в очереди на индексирование", - "n_notes_queued_2": "{{ count }} заметок в очереди на индексирование", "no_models_found_ollama": "Модели Ollama не найдены. Проверьте, запущена ли Ollama.", "no_models_found_online": "Модели не найдены. Проверьте ваш ключ API и настройки.", "experimental_warning": "Функция LLM в настоящее время является экспериментальной — вы предупреждены.", "ollama_no_url": "Ollama не настроена. Введите корректный URL-адрес.", - "notes_indexed_0": "{{ count }} заметка проиндексирована", - "notes_indexed_1": "{{ count }} заметки проиндексировано", - "notes_indexed_2": "{{ count }} заметок проиндексировано", "show_thinking_description": "Показать цепочку мыслительного процесса ИИ", "api_key_tooltip": "API-ключ для доступа к сервису", "all_notes_queued_for_retry": "Все неудачные заметки поставлены в очередь на повторную попытку", @@ -2032,7 +2023,8 @@ "placeholder": "Введите содержимое для заметки с кодом..." }, "editable_text": { - "placeholder": "Введите содержимое для заметки..." + "placeholder": "Введите содержимое для заметки...", + "auto-detect-language": "Определен автоматически" }, "hoisted_note": { "confirm_unhoisting": "Запрошенная заметка «{{requestedNote}}» находится за пределами поддерева закрепленной заметки \"{{hoistedNote}}\", и для доступа к ней необходимо снять закрепление. Открепить заметку?" diff --git a/apps/client/src/translations/sr/translation.json b/apps/client/src/translations/sr/translation.json index df88fdcdaf..2c4956af7e 100644 --- a/apps/client/src/translations/sr/translation.json +++ b/apps/client/src/translations/sr/translation.json @@ -1,496 +1,488 @@ { - "about": { - "title": "O Trilium Belеškama", - "homepage": "Početna stranica:", - "app_version": "Verzija aplikacije:", - "db_version": "Verzija baze podataka:", - "sync_version": "Verzija sinhronizacije:", - "build_date": "Datum izgradnje:", - "build_revision": "Revizija izgradnje:", - "data_directory": "Direktorijum sa podacima:" + "about": { + "title": "O Trilium Belеškama", + "homepage": "Početna stranica:", + "app_version": "Verzija aplikacije:", + "db_version": "Verzija baze podataka:", + "sync_version": "Verzija sinhronizacije:", + "build_date": "Datum izgradnje:", + "build_revision": "Revizija izgradnje:", + "data_directory": "Direktorijum sa podacima:" + }, + "toast": { + "critical-error": { + "title": "Kritična greška", + "message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku." }, - "toast": { - "critical-error": { - "title": "Kritična greška", - "message": "Došlo je do kritične greške koja sprečava pokretanje klijentske aplikacije.\n\n{{message}}\n\nOva greška je najverovatnije izazvana neočekivanim problemom prilikom izvršavanja skripte. Pokušajte da pokrenete aplikaciju u bezbednom režimu i da pronađete šta izaziva grešku." - }, - "widget-error": { - "title": "Pokretanje vidžeta nije uspelo", - "message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}", - "message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}" - }, - "bundle-error": { - "title": "Pokretanje prilagođene skripte neuspešno", - "message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}" - } + "widget-error": { + "title": "Pokretanje vidžeta nije uspelo", + "message-custom": "Prilagođeni viđet sa beleške sa ID-jem \"{{id}}\", nazivom \"{{title}}\" nije uspeo da se pokrene zbog:\n\n{{message}}", + "message-unknown": "Nepoznati vidžet nije mogao da se pokrene zbog:\n\n{{message}}" }, - "add_link": { - "add_link": "Dodaj link", - "help_on_links": "Pomoć na linkovima", - "note": "Beleška", - "search_note": "potražite belešku po njenom imenu", - "link_title_mirrors": "naziv linka preslikava trenutan naziv beleške", - "link_title_arbitrary": "naziv linka se može proizvoljno menjati", - "link_title": "Naziv linka", - "button_add_link": "Dodaj link enter" - }, - "branch_prefix": { - "edit_branch_prefix": "Izmeni prefiks grane", - "help_on_tree_prefix": "Pomoć na prefiksu Drveta", - "prefix": "Prefiks: ", - "save": "Sačuvaj", - "branch_prefix_saved": "Prefiks grane je sačuvan." - }, - "bulk_actions": { - "bulk_actions": "Grupne akcije", - "affected_notes": "Pogođene beleške", - "include_descendants": "Obuhvati potomke izabranih beleški", - "available_actions": "Dostupne akcije", - "chosen_actions": "Izabrane akcije", - "execute_bulk_actions": "Izvrši grupne akcije", - "bulk_actions_executed": "Grupne akcije su uspešno izvršene.", - "none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.", - "labels": "Oznake", - "relations": "Odnosi", - "notes": "Beleške", - "other": "Ostalo" - }, - "clone_to": { - "clone_notes_to": "Klonirajte beleške u...", - "help_on_links": "Pomoć na linkovima", - "notes_to_clone": "Beleške za kloniranje", - "target_parent_note": "Ciljna nadređena beleška", - "search_for_note_by_its_name": "potražite belešku po njenom imenu", - "cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom", - "prefix_optional": "Prefiks (opciono)", - "clone_to_selected_note": "Kloniranje u izabranu belešku enter", - "no_path_to_clone_to": "Nema putanje za kloniranje.", - "note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\"" - }, - "confirm": { - "confirmation": "Potvrda", - "cancel": "Otkaži", - "ok": "U redu", - "are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ", - "if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.", - "also_delete_note": "Takođe obriši belešku" - }, - "delete_notes": { - "delete_notes_preview": "Obriši pregled beleške", - "close": "Zatvori", - "delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)", - "erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.", - "erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.", - "notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})", - "no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).", - "broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})", - "cancel": "Otkaži", - "ok": "U redu", - "deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}." - }, - "export": { - "export_note_title": "Izvezi belešku", - "close": "Zatvori", - "export_type_subtree": "Ova beleška i svi njeni potomci", - "format_html": "HTML - preporučuje se jer čuva formatiranje", - "format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.", - "format_markdown": "Markdown - ovo čuva većinu formatiranja.", - "format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.", - "opml_version_1": "OPML v1.0 - samo običan tekst", - "opml_version_2": "OPML v2.0 - dozvoljava i HTML", - "export_type_single": "Samo ovu belešku bez njenih potomaka", - "export": "Izvoz", - "choose_export_type": "Molimo vas da prvo izaberete tip izvoza", - "export_status": "Status izvoza", - "export_in_progress": "Izvoz u toku: {{progressCount}}", - "export_finished_successfully": "Izvoz je uspešno završen.", - "format_pdf": "PDF - za namene štampanja ili deljenja." - }, - "help": { - "noteNavigation": "Navigacija beleški", - "goUpDown": "UP, DOWN - kretanje gore/dole u listi sa beleškama", - "collapseExpand": "LEFT, RIGHT - sakupi/proširi čvor", - "notSet": "nije podešeno", - "goBackForwards": "idi u nazad/napred kroz istoriju", - "showJumpToNoteDialog": "prikaži \"Idi na\" dijalog", - "scrollToActiveNote": "skroluj do aktivne beleške", - "jumpToParentNote": "idi do nadređene beleške", - "collapseWholeTree": "sakupi celo drvo beleški", - "collapseSubTree": "sakupi pod-drvo", - "tabShortcuts": "Prečice na karticama", - "newTabNoteLink": "na link beleške otvara belešku u novoj kartici", - "newTabWithActivationNoteLink": "na link beleške otvara i aktivira belešku u novoj kartici", - "onlyInDesktop": "Samo na dektop-u (Electron verzija)", - "openEmptyTab": "otvori praznu karticu", - "closeActiveTab": "zatvori aktivnu karticu", - "activateNextTab": "aktiviraj narednu karticu", - "activatePreviousTab": "aktiviraj prethodnu karticu", - "creatingNotes": "Pravljenje beleški", - "createNoteAfter": "napravi novu belešku nakon aktivne beleške", - "createNoteInto": "napravi novu pod-belešku u aktivnoj belešci", - "editBranchPrefix": "izmeni prefiks klona aktivne beleške", - "movingCloningNotes": "Premeštanje / kloniranje beleški", - "moveNoteUpDown": "pomeri belešku gore/dole u listi beleški", - "moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji", - "multiSelectNote": "višestruki izbor beleški iznad/ispod", - "selectAllNotes": "izaberi sve beleške u trenutnom nivou", - "selectNote": "izaberi belešku", - "copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za kloniranje)", - "cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)", - "pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)", - "deleteNotes": "obriši belešku / podstablo", - "editingNotes": "Izmena beleški", - "editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. Ctrl+. će se vratiti sa uređivača na ravan drveta.", - "createEditLink": "napravi / izmeni spoljašnji link", - "createInternalLink": "napravi unutrašnji link", - "followLink": "prati link ispod kursora", - "insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora", - "jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške", - "markdownAutoformat": "Autoformatiranje kao u Markdown-u", - "headings": "##, ###, #### itd. praćeno razmakom za naslove", - "bulletList": "* ili - praćeno razmakom za listu sa tačkama", - "numberedList": "1. ili 1) praćeno razmakom za numerisanu listu", - "blockQuote": "započnite liniju sa > praćeno sa razmakom za blok citat", - "troubleshooting": "Rešavanje problema", - "reloadFrontend": "ponovo učitaj Trilium frontend", - "showDevTools": "prikaži alate za programere", - "showSQLConsole": "prikaži SQL konzolu", - "other": "Ostalo", - "quickSearch": "fokus na unos za brzu pretragu", - "inPageSearch": "pretraga unutar stranice" - }, - "import": { - "importIntoNote": "Uvezi u belešku", - "chooseImportFile": "Izaberi datoteku za uvoz", - "importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u", - "options": "Opcije", - "safeImportTooltip": "Trilium .zip izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.", - "safeImport": "Bezbedan uvoz", - "explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati .zip, .enex i .opml datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.", - "explodeArchives": "Pročitaj sadržaj .zip, .enex i .opml arhiva.", - "shrinkImagesTooltip": "

Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.

Ovo se ne primenjuje na .zip uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.

", - "shrinkImages": "Smanji slike", - "textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka", - "codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput .json) ako beleške sa kodom ako nije jasno iz metapodataka", - "replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški", - "import": "Uvezi", - "failed": "Uvoz nije uspeo: {{message}}.", - "html_import_tags": { - "title": "HTML oznake za uvoz", - "description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.", - "placeholder": "Unesite HTML oznake, po jednu u svaki red", - "reset_button": "Vrati na podrazumevanu listu" - }, - "import-status": "Status uvoza", - "in-progress": "Uvoz u toku: {{progress}}", - "successful": "Uvoz je uspešno završen." - }, - "include_note": { - "dialog_title": "Uključi belešku", - "label_note": "Beleška", - "placeholder_search": "pretraži belešku po njenom imenu", - "box_size_prompt": "Veličina kutije priložene beleške:", - "box_size_small": "mala (~ 10 redova)", - "box_size_medium": "srednja (~ 30 redova)", - "box_size_full": "puna (kutija prikazuje ceo tekst)", - "button_include": "Uključi belešku" - }, - "info": { - "modalTitle": "Informativna poruka", - "closeButton": "Zatvori", - "okButton": "U redu" - }, - "jump_to_note": { - "search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...", - "search_button": "Pretraga u punom tekstu Ctrl+Enter" - }, - "markdown_import": { - "dialog_title": "Uvoz za Markdown", - "modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz", - "import_button": "Uvoz", - "import_success": "Markdown sadržaj je učitan u dokument." - }, - "move_to": { - "dialog_title": "Premesti beleške u ...", - "notes_to_move": "Beleške za premeštanje", - "target_parent_note": "Ciljana nadbeleška", - "search_placeholder": "potraži belešku po njenom imenu", - "move_button": "Pređi na izabranu belešku", - "error_no_path": "Nema putanje za premeštanje.", - "move_success_message": "Izabrane beleške su premeštene u " - }, - "note_type_chooser": { - "change_path_prompt": "Promenite gde će se napraviti nova beleška:", - "search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)", - "modal_title": "Izaberite tip beleške", - "modal_body": "Izaberite tip beleške / šablon za novu belešku:", - "templates": "Šabloni" - }, - "password_not_set": { - "title": "Lozinka nije podešena", - "body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.", - "body2": "Za biste mogli da sačuvate beleške, kliknite ovde da otvorite dijalog sa Opcijama i podesite svoju lozinku." - }, - "prompt": { - "title": "Upit", - "ok": "U redu enter", - "defaultTitle": "Upit" - }, - "protected_session_password": { - "modal_title": "Zaštićena sesija", - "help_title": "Pomoć za Zaštićene beleške", - "close_label": "Zatvori", - "form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:", - "start_button": "Započni zaštićenu sesiju" - }, - "recent_changes": { - "title": "Nedavne promene", - "erase_notes_button": "Obriši izabrane beleške odmah", - "deleted_notes_message": "Obrisane beleške su uklonjene.", - "no_changes_message": "Još uvek nema izmena...", - "undelete_link": "poništi brisanje", - "confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?" - }, - "revisions": { - "note_revisions": "Revizije beleški", - "delete_all_revisions": "Obriši sve revizije ove beleške", - "delete_all_button": "Obriši sve revizije", - "help_title": "Pomoć za Revizije beleški", - "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?", - "no_revisions": "Još uvek nema revizija za ovu belešku...", - "restore_button": "Vrati", - "confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.", - "delete_button": "Obriši", - "confirm_delete": "Da li želite da obrišete ovu reviziju?", - "revisions_deleted": "Revizije beleške su obrisane.", - "revision_restored": "Revizija beleške je vraćena.", - "revision_deleted": "Revizija beleške je obrisana.", - "snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.", - "maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.", - "settings": "Podešavanja revizija beleški", - "download_button": "Preuzmi", - "mime": "MIME: ", - "file_size": "Veličina datoteke:", - "preview": "Pregled:", - "preview_not_available": "Pregled nije dostupan za ovaj tip beleške." - }, - "sort_child_notes": { - "sort_children_by": "Sortiranje podbeleški po...", - "sorting_criteria": "Kriterijum za sortiranje", - "title": "naslov", - "date_created": "datum kreiranja", - "date_modified": "datum izmene", - "sorting_direction": "Smer sortiranja", - "ascending": "uzlazni", - "descending": "silazni", - "folders": "Fascikle", - "sort_folders_at_top": "sortiraj fascikle na vrh", - "natural_sort": "Prirodno sortiranje", - "sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.", - "natural_sort_language": "Jezik za prirodno sortiranje", - "the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.", - "sort": "Sortiraj" - }, - "upload_attachments": { - "upload_attachments_to_note": "Otpremite priloge uz belešku", - "choose_files": "Izaberite datoteke", - "files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u {{noteTitle}}", - "options": "Opcije", - "shrink_images": "Smanji slike", - "upload": "Otpremi", - "tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena." - }, - "attribute_detail": { - "attr_detail_title": "Naslov detalja atributa", - "close_button_title": "Otkaži izmene i zatvori", - "attr_is_owned_by": "Atribut je u vlasništvu", - "attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte", - "name": "Naziv", - "value": "Vrednost", - "target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.", - "target_note": "Ciljna beleška", - "promoted_title": "Promovisani atribut je istaknut na belešci.", - "promoted": "Promovisan", - "promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.", - "promoted_alias": "Pseudonim", - "multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.", - "multiplicity": "Multiplicitet", - "single_value": "Jednostruka vrednost", - "multi_value": "Višestruka vrednost", - "label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.", - "label_type": "Tip", - "text": "Tekst", - "number": "Broj", - "boolean": "Boolean", - "date": "Datum", - "date_time": "Datum i vreme", - "time": "Vreme", - "url": "URL", - "precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.", - "precision": "Preciznost", - "digits": "cifre", - "inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.", - "inverse_relation": "Inverzna relacija", - "inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.", - "inheritable": "Nasledno", - "save_and_close": "Sačuvaj i zatvori Ctrl+Enter", - "delete": "Obriši", - "related_notes_title": "Druge beleške sa ovom oznakom", - "more_notes": "Još beleški", - "label": "Detalji oznake", - "label_definition": "Detalji definicije oznake", - "relation": "Detalji relacije", - "relation_definition": "Detalji definicije relacije", - "disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte", - "calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.", - "archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).", - "exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški", - "run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n
    \n
  • frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.
  • \n
  • mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..
  • \n
  • backendStartup - kada se Trilium backend pokrene
  • \n
  • hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka runAtHour da se označi u kom satu.
  • \n
  • daily - pokreće se jednom dnevno
  • \n
", - "run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.", - "run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa #run=hourly. Može biti definisano više puta za više pokretanja u toku dana.", - "disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.", - "sorted": "čuva podbeleške sortirane alfabetski po naslovu", - "sort_direction": "Uzlazno (podrazumevano) ili silazno", - "sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu", - "top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)", - "hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci", - "read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.", - "auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku", - "app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.", - "app_theme": "označava CSS beleške koje su pune Trilium teme i stoga su dostupne u Trilium podešavanjima.", - "app_theme_base": "podesite na „sledeće“, „sledeće-svetlo“ ili „sledeće-tamno“ da biste koristili odgovarajuću TriliumNext temu (automatsku, svetlu ili tamnu) kao osnovu za prilagođenu temu, umesto podrazumevane teme.", - "css_class": "vrednost ove oznake se zatim dodaje kao CSS klasa čvoru koji predstavlja datu belešku u stablu. Ovo može biti korisno za napredno temiranje. Može se koristiti u šablonima beleški.", - "workspace": "označava ovu belešku kao radni prostor što omogućava lako podizanje", - "workspace_icon_class": "definiše CSS klasu ikone okvira koja će se koristiti u kartici kada se podigne na ovoj belešci", - "workspace_tab_background_color": "CSS boja korišćena u kartici beleške kada se prebaci na ovu belešku", - "workspace_calendar_root": "Definiše koren kalendara za svaki radni prostor", - "workspace_template": "Ova beleška će se pojaviti u izboru dostupnih šablona prilikom kreiranja nove beleške, ali samo kada se podigne u radni prostor koji sadrži ovaj šablon", - "search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške", - "workspace_search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške kada se podignu na nekog pretka ove beleške iz radnog prostora", - "inbox": "podrazumevana lokacija u prijemnom sandučetu za nove beleške - kada kreirate belešku pomoću dugmeta „nova beleška“ u bočnoj traci, beleške će biti kreirane kao podbeleške u belešci označenoj sa oznakom #inbox.", - "workspace_inbox": "podrazumevana lokacija prijemnog sandučeta za nove beleške kada se prebace na nekog pretka ove beleške iz radnog prostora", - "sql_console_home": "podrazmevana lokacija beleški SQL konzole", - "bookmark_folder": "beleška sa ovom oznakom će se pojaviti u obeleživačima kao fascikla (omogućavajući pristup njenim podređenim fasciklama)", - "share_hidden_from_tree": "ova beleška je skrivena u levom navigacionom stablu, ali je i dalje dostupna preko svoje URL adrese", - "share_external_link": "beleška će služiti kao veza ka eksternoj veb stranici u stablu deljenja", - "share_alias": "definišite alias pomoću kog će beleška biti dostupna na https://your_trilium_host/share/[your_alias]", - "share_omit_default_css": "CSS kod podrazumevane stranice za deljenje će biti izostavljen. Koristite ga kada pravite opsežne promene stila.", - "share_root": "obeležava belešku koja se prikazuje na /share korenu.", - "share_description": "definišite tekst koji će se dodati HTML meta oznaci za opis", - "share_raw": "beleška će biti prikazana u svom sirovom (raw) formatu, bez HTML omotača", - "share_disallow_robot_indexing": "zabraniće robotsko indeksiranje ove beleške putem zaglavlja X-Robots-Tag: noindex", - "share_credentials": "potrebni su kredencijali za pristup ovoj deljenoj belešci. Očekuje se da vrednost bude u formatu „korisničko ime:lozinka“. Ne zaboravite da ovo označite kao nasledno da bi se primenilo na podbeleške/slike.", - "share_index": "beleška sa ovom oznakom će izlistati sve korene deljenih beleški", - "display_relations": "imena relacija razdvojenih zarezima koja treba da budu prikazana. Sva ostala će biti skrivena.", - "hide_relations": "imena relacija razdvojenih zarezima koja treba da budu skrivena. Sva ostala će biti prikazana.", - "title_template": "podrazumevani naslov beleški kreiranih kao deca ove beleške. Vrednost se procenjuje kao JavaScript string \n i stoga se može obogatiti dinamičkim sadržajem putem ubrizganih promenljivih now and parentNote. Primeri:\n \n
    \n
  • ${parentNote.getLabelValue('authorName')}'s literary works
  • \n
  • Log for ${now.format('YYYY-MM-DD HH:mm:ss')}
  • \n
\n \n Pogledati wiki sa detaljima, API dokumentacija za parentNote i now za detalje.", - "template": "Ova beleška će biti prikazana u izboru dostupnih šablona prilikom pravljenja nove beleške", - "toc": "#toc ili #toc=show će pristiliti Sadržaj (Table of Contents) da bude prikazan, #toc=hide prisiliti njegovo sakrivanje. Ako oznaka ne postoji, ponašanje će biti usklađeno sa globalnim podešavanjem", - "color": "definiše boju beleške u stablu beleški, linkovima itd. Koristite bilo koju važeću CSS vrednost boje kao što je „crvena“ ili #a13d5f", - "keyboard_shortcut": "Definiše prečicu na tastaturi koja će odmah preći na ovu belešku. Primer: „ctrl+alt+e“. Potrebno je ponovno učitavanje frontenda da bi promena stupila na snagu.", - "keep_current_hoisting": "Otvaranje ove veze neće promeniti podizanje čak i ako beleška nije prikazana u trenutno podignutom podstablu.", - "execute_button": "Naslov dugmeta koje će izvršiti trenutnu belešku sa kodom", - "execute_description": "Duži opis trenutne beleške sa kodom prikazan je zajedno sa dugmetom za izvršavanje", - "exclude_from_note_map": "Beleške sa ovom oznakom biće skrivene sa mape beleški", - "new_notes_on_top": "Nove beleške će biti napravljene na vrhu matične beleške, a ne na dnu.", - "hide_highlight_widget": "Sakrij vidžet sa listom istaknutih", - "run_on_note_creation": "izvršava se kada se beleška napravi na serverskoj strani. Koristite ovu relaciju ako želite da pokrenete skriptu za sve beleške napravljene u okviru određenog podstabla. U tom slučaju, kreirajte je na korenu beleške podstabla i učinite je naslednom. Nova beleška napravljena unutar podstabla (bilo koje dubine) pokrenuće skriptu.", - "run_on_child_note_creation": "izvršava se kada se napravi nova beleška ispod beleške gde je ova relacija definisana", - "run_on_note_title_change": "izvršava se kada se promeni naslov beleške (uključuje i pravljenje beleške)", - "run_on_note_content_change": "izvršava se kada se promeni sadržaj beleške (uključuje i pravljenje beleške).", - "run_on_note_change": "izvršava se kada se promeni beleška (uključuje i pravljenje beleške). Ne uključuje promene sadržaja", - "icon_class": "vrednost ove oznake se dodaje kao CSS klasa ikoni na stablu što može pomoći u vizuelnom razlikovanju beleški u stablu. Primer može biti bx bx-home - ikone su preuzete iz boxicons. Može se koristiti u šablonima beleški.", - "page_size": "broj stavki po stranici u listi beleški", - "custom_request_handler": "pogledajte Prilagođeni obrađivač zahteva", - "custom_resource_provider": "pogledajte Prilagođeni obrađivač zahteva", - "widget": "označava ovu belešku kao prilagođeni vidžet koji će biti dodat u stablo komponenti Trilijuma", - "run_on_note_deletion": "izvršava se kada se beleška briše", - "run_on_branch_creation": "izvršava se kada se pravi grana. Grana je veza između matične i podređene beleške i pravi se npr. prilikom kloniranja ili premeštanja beleške.", - "run_on_branch_change": "izvršava se kada se grana ažurira.", - "run_on_branch_deletion": "izvršava se kada se grana briše. Grana je veza između nadređene beleške i podređene beleške i briše se npr. prilikom premeštanja beleške (stara grana/veza se briše).", - "run_on_attribute_creation": "izvršava se kada se pravi novi atribut za belešku koji definiše ovu relaciju", - "run_on_attribute_change": " izvršava se kada se promeni atribut beleške koja definiše ovu relaciju. Ovo se pokreće i kada se atribut obriše", - "relation_template": "atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete, sadržaj i podstablo beleške će biti dodati instanci beleške ako je prazna. Pogledajte dokumentaciju za detalje.", - "inherit": "Atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete. Pogledajte relaciju šablona za sličan koncept. Pogledajte nasleđivanje atributa u dokumentaciji.", - "render_note": "Beleške tipa „render HTML note“ će biti prikazane korišćenjem beleške za kod (HTML ili skripte) i potrebno je pomoću ove relacije ukazati na to koja beleška treba da se prikaže", - "widget_relation": "meta ove relacije će biti izvršena i prikazana kao vidžet u bočnoj traci", - "share_css": "CSS napomena koja će biti ubrizgana na stranicu za deljenje. CSS napomena mora biti i u deljenom podstablu. Razmotrite i korišćenje „share_hidden_from_tree“ i „share_omit_default_css“.", - "share_js": "JavaScript beleška koja će biti ubrizgana na stranicu za deljenje. JS beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.", - "share_template": "Ugrađena JavaScript beleška koja će se koristiti kao šablon za prikazivanje deljene beleške. U slučaju neuspeha vraća se na podrazumevani šablon. Razmislite o korišćenju „share_hidden_from_tree“.", - "share_favicon": "Favicon beleška koju treba postaviti na deljenu stranicu. Obično je potrebno da je podesite da deli koren i učinite je naslednom. Favicon beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.", - "is_owned_by_note": "je u vlasništvu beleške", - "other_notes_with_name": "Ostale beleške sa {{attributeType}} nazivom „{{attributeName}}“", - "and_more": "... i još {{count}}.", - "print_landscape": "Prilikom izvoza u PDF, menja orijentaciju stranice u pejzažnu umesto uspravne.", - "print_page_size": "Prilikom izvoza u PDF, menja veličinu stranice. Podržane vrednosti: A0, A1, A2, A3, A4, A5, A6, Legal, Letter, Tabloid, Ledger.", - "color_type": "Boja" - }, - "ai_llm": { - "n_notes_queued_0": "{{ count }} beleška stavljena u red za indeksiranje", - "n_notes_queued_1": "{{ count }} beleški stavljeno u red za indeksiranje", - "n_notes_queued_2": "{{ count }} beleški stavljeno u red za indeksiranje", - "notes_indexed_0": "{{ count }} beleška je indeksirana", - "notes_indexed_1": "{{ count }} beleški je indeksirano", - "notes_indexed_2": "{{ count }} beleški je indeksirano" - }, - "attribute_editor": { - "help_text_body1": "Da biste dodali oznaku, samo unesite npr. #rock ili ako želite da dodate i vrednost, onda npr. #year = 2020", - "help_text_body2": "Za relaciju, unesite ~author = @ što bi trebalo da otvori automatsko dovršavanje gde možete potražiti željenu belešku.", - "help_text_body3": "Alternativno, možete dodati oznaku i relaciju pomoću dugmeta + sa desne strane.", - "save_attributes": "Sačuvaj atribute ", - "add_a_new_attribute": "Dodajte novi atribut", - "add_new_label": "Dodajte novu oznaku ", - "add_new_relation": "Dodajte novu relaciju ", - "add_new_label_definition": "Dodajte novu definiciju oznake", - "add_new_relation_definition": "Dodajte novu definiciju relacije", - "placeholder": "Ovde unesite oznake i relacije" - }, - "abstract_bulk_action": { - "remove_this_search_action": "Ukloni ovu radnju pretrage" - }, - "execute_script": { - "execute_script": "Izvrši skriptu", - "help_text": "Možete izvršiti jednostavne skripte na podudarnim beleškama.", - "example_1": "Na primer, da biste dodali string u naslov beleške, koristite ovu malu skriptu:", - "example_2": "Složeniji primer bi bio brisanje svih atributa podudarnih beleški:" - }, - "add_label": { - "add_label": "Dodaj oznaku", - "label_name_placeholder": "ime oznake", - "label_name_title": "Alfanumerički znakovi, donja crta i dvotačka su dozvoljeni znakovi.", - "to_value": "za vrednost", - "new_value_placeholder": "nova vrednost", - "help_text": "Na svim podudarnim beleškama:", - "help_text_item1": "dodajte datu oznaku ako beleška još uvek nema jednu", - "help_text_item2": "ili izmenite vrednost postojeće oznake", - "help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti." - }, - "delete_label": { - "delete_label": "Obriši oznaku", - "label_name_placeholder": "ime oznake", - "label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi." - }, - "rename_label": { - "rename_label": "Preimenuj oznaku", - "rename_label_from": "Preimenuj oznaku iz", - "old_name_placeholder": "stari naziv", - "to": "U", - "new_name_placeholder": "novi naziv", - "name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi." - }, - "update_label_value": { - "update_label_value": "Ažuriraj vrednost oznake", - "label_name_placeholder": "ime oznake", - "label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi.", - "to_value": "u vrednost", - "new_value_placeholder": "nova vrednost", - "help_text": "Na svim podudarnim beleškama, promenite vrednost postojeće oznake.", - "help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti." - }, - "delete_note": { - "delete_note": "Obriši belešku", - "delete_matched_notes": "Obriši podudarne beleške", - "delete_matched_notes_description": "Ovo će obrisati podudarne beleške.", - "undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene." + "bundle-error": { + "title": "Pokretanje prilagođene skripte neuspešno", + "message": "Skripta iz beleške sa ID-jem \"{{id}}\", naslovom \"{{title}}\" nije mogla da se izvrši zbog:\n\n{{message}}" } + }, + "add_link": { + "add_link": "Dodaj link", + "help_on_links": "Pomoć na linkovima", + "note": "Beleška", + "search_note": "potražite belešku po njenom imenu", + "link_title_mirrors": "naziv linka preslikava trenutan naziv beleške", + "link_title_arbitrary": "naziv linka se može proizvoljno menjati", + "link_title": "Naziv linka", + "button_add_link": "Dodaj link enter" + }, + "branch_prefix": { + "edit_branch_prefix": "Izmeni prefiks grane", + "help_on_tree_prefix": "Pomoć na prefiksu Drveta", + "prefix": "Prefiks: ", + "save": "Sačuvaj", + "branch_prefix_saved": "Prefiks grane je sačuvan." + }, + "bulk_actions": { + "bulk_actions": "Grupne akcije", + "affected_notes": "Pogođene beleške", + "include_descendants": "Obuhvati potomke izabranih beleški", + "available_actions": "Dostupne akcije", + "chosen_actions": "Izabrane akcije", + "execute_bulk_actions": "Izvrši grupne akcije", + "bulk_actions_executed": "Grupne akcije su uspešno izvršene.", + "none_yet": "Nijedna za sad... dodajte akciju tako što ćete pritisnuti na neku od dostupnih akcija iznad.", + "labels": "Oznake", + "relations": "Odnosi", + "notes": "Beleške", + "other": "Ostalo" + }, + "clone_to": { + "clone_notes_to": "Klonirajte beleške u...", + "help_on_links": "Pomoć na linkovima", + "notes_to_clone": "Beleške za kloniranje", + "target_parent_note": "Ciljna nadređena beleška", + "search_for_note_by_its_name": "potražite belešku po njenom imenu", + "cloned_note_prefix_title": "Klonirana beleška će biti prikazana u drvetu beleški sa datim prefiksom", + "prefix_optional": "Prefiks (opciono)", + "clone_to_selected_note": "Kloniranje u izabranu belešku enter", + "no_path_to_clone_to": "Nema putanje za kloniranje.", + "note_cloned": "Beleška \"{{clonedTitle}}\" je klonirana u \"{{targetTitle}}\"" + }, + "confirm": { + "confirmation": "Potvrda", + "cancel": "Otkaži", + "ok": "U redu", + "are_you_sure_remove_note": "Da li ste sigurni da želite da uklonite belešku \"{{title}}\" iz mape odnosa? ", + "if_you_dont_check": "Ako ne izaberete ovo, beleška će biti uklonjena samo sa mape odnosa.", + "also_delete_note": "Takođe obriši belešku" + }, + "delete_notes": { + "delete_notes_preview": "Obriši pregled beleške", + "close": "Zatvori", + "delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)", + "erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.", + "erase_notes_warning": "Trajno obriši beleške (ne može se opozvati), uključujući sve klonove. Ovo će prisiliti aplikaciju da se ponovo pokrene.", + "notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})", + "no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).", + "broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})", + "cancel": "Otkaži", + "ok": "U redu", + "deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}." + }, + "export": { + "export_note_title": "Izvezi belešku", + "close": "Zatvori", + "export_type_subtree": "Ova beleška i svi njeni potomci", + "format_html": "HTML - preporučuje se jer čuva formatiranje", + "format_html_zip": "HTML u ZIP arhivi - ovo se preporučuje jer se na taj način čuva celokupno formatiranje.", + "format_markdown": "Markdown - ovo čuva većinu formatiranja.", + "format_opml": "OPML - format za razmenu okvira samo za tekst. Formatiranje, slike i datoteke nisu uključeni.", + "opml_version_1": "OPML v1.0 - samo običan tekst", + "opml_version_2": "OPML v2.0 - dozvoljava i HTML", + "export_type_single": "Samo ovu belešku bez njenih potomaka", + "export": "Izvoz", + "choose_export_type": "Molimo vas da prvo izaberete tip izvoza", + "export_status": "Status izvoza", + "export_in_progress": "Izvoz u toku: {{progressCount}}", + "export_finished_successfully": "Izvoz je uspešno završen.", + "format_pdf": "PDF - za namene štampanja ili deljenja." + }, + "help": { + "noteNavigation": "Navigacija beleški", + "goUpDown": "UP, DOWN - kretanje gore/dole u listi sa beleškama", + "collapseExpand": "LEFT, RIGHT - sakupi/proširi čvor", + "notSet": "nije podešeno", + "goBackForwards": "idi u nazad/napred kroz istoriju", + "showJumpToNoteDialog": "prikaži \"Idi na\" dijalog", + "scrollToActiveNote": "skroluj do aktivne beleške", + "jumpToParentNote": "idi do nadređene beleške", + "collapseWholeTree": "sakupi celo drvo beleški", + "collapseSubTree": "sakupi pod-drvo", + "tabShortcuts": "Prečice na karticama", + "newTabNoteLink": "na link beleške otvara belešku u novoj kartici", + "newTabWithActivationNoteLink": "na link beleške otvara i aktivira belešku u novoj kartici", + "onlyInDesktop": "Samo na dektop-u (Electron verzija)", + "openEmptyTab": "otvori praznu karticu", + "closeActiveTab": "zatvori aktivnu karticu", + "activateNextTab": "aktiviraj narednu karticu", + "activatePreviousTab": "aktiviraj prethodnu karticu", + "creatingNotes": "Pravljenje beleški", + "createNoteAfter": "napravi novu belešku nakon aktivne beleške", + "createNoteInto": "napravi novu pod-belešku u aktivnoj belešci", + "editBranchPrefix": "izmeni prefiks klona aktivne beleške", + "movingCloningNotes": "Premeštanje / kloniranje beleški", + "moveNoteUpDown": "pomeri belešku gore/dole u listi beleški", + "moveNoteUpHierarchy": "pomeri belešku na gore u hijerarhiji", + "multiSelectNote": "višestruki izbor beleški iznad/ispod", + "selectAllNotes": "izaberi sve beleške u trenutnom nivou", + "selectNote": "izaberi belešku", + "copyNotes": "kopiraj aktivnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za kloniranje)", + "cutNotes": "iseci trenutnu belešku (ili trenutni izbor) u privremenu memoriju (koristi se za premeštanje beleški)", + "pasteNotes": "nalepi belešku/e kao podbelešku u aktivnoj belešci (koja se ili premešta ili klonira u zavisnosti od toga da li je beleška kopirana ili isečena u privremenu memoriju)", + "deleteNotes": "obriši belešku / podstablo", + "editingNotes": "Izmena beleški", + "editNoteTitle": "u ravni drveta će se prebaciti sa ravni drveta na naslov beleške. Ulaz sa naslova beleške će prebaciti fokus na uređivač teksta. Ctrl+. će se vratiti sa uređivača na ravan drveta.", + "createEditLink": "napravi / izmeni spoljašnji link", + "createInternalLink": "napravi unutrašnji link", + "followLink": "prati link ispod kursora", + "insertDateTime": "ubaci trenutan datum i vreme na poziciju kursora", + "jumpToTreePane": "idi na ravan stabla i pomeri se do aktivne beleške", + "markdownAutoformat": "Autoformatiranje kao u Markdown-u", + "headings": "##, ###, #### itd. praćeno razmakom za naslove", + "bulletList": "* ili - praćeno razmakom za listu sa tačkama", + "numberedList": "1. ili 1) praćeno razmakom za numerisanu listu", + "blockQuote": "započnite liniju sa > praćeno sa razmakom za blok citat", + "troubleshooting": "Rešavanje problema", + "reloadFrontend": "ponovo učitaj Trilium frontend", + "showDevTools": "prikaži alate za programere", + "showSQLConsole": "prikaži SQL konzolu", + "other": "Ostalo", + "quickSearch": "fokus na unos za brzu pretragu", + "inPageSearch": "pretraga unutar stranice" + }, + "import": { + "importIntoNote": "Uvezi u belešku", + "chooseImportFile": "Izaberi datoteku za uvoz", + "importDescription": "Sadržaj izabranih datoteka će biti uvezen kao podbeleške u", + "options": "Opcije", + "safeImportTooltip": "Trilium .zip izvozne datoteke mogu da sadrže izvršne skripte koje mogu imati štetno ponašanje. Bezbedan uvoz će deaktivirati automatsko izvršavanje svih uvezenih skripti. Isključite \"Bezbedan uvoz\" samo ako uvezena arhiva treba da sadrži izvršne skripte i ako potpuno verujete sadržaju uvezene datoteke.", + "safeImport": "Bezbedan uvoz", + "explodeArchivesTooltip": "Ako je ovo označeno onda će Trilium pročitati .zip, .enex i .opml datoteke i napraviti beleške od datoteka unutar tih arhiva. Ako nije označeno, Trilium će same arhive priložiti belešci.", + "explodeArchives": "Pročitaj sadržaj .zip, .enex i .opml arhiva.", + "shrinkImagesTooltip": "

Ako označite ovu opciju, Trilium će pokušati da smanji uvezene slike skaliranjem i optimizacijom što će možda uticati na kvalitet slike. Ako nije označeno, slike će biti uvezene bez promena.

Ovo se ne primenjuje na .zip uvoze sa metapodacima jer se tada podrazumeva da su te datoteke već optimizovane.

", + "shrinkImages": "Smanji slike", + "textImportedAsText": "Uvezi HTML, Markdown i TXT kao tekstualne beleške ako je nejasno iz metapodataka", + "codeImportedAsCode": "Uvezi prepoznate datoteke sa kodom (poput .json) ako beleške sa kodom ako nije jasno iz metapodataka", + "replaceUnderscoresWithSpaces": "Zameni podvlake sa razmacima u nazivima uvezenih beleški", + "import": "Uvezi", + "failed": "Uvoz nije uspeo: {{message}}.", + "html_import_tags": { + "title": "HTML oznake za uvoz", + "description": "Podesite koje HTML oznake trebaju biti sačuvane kada se uvoze beleške. Oznake koje se ne nalaze na listi će biti uklonjene tokom uvoza. Pojedine oznake (poput 'script') se uvek uklanjaju zbog bezbednosti.", + "placeholder": "Unesite HTML oznake, po jednu u svaki red", + "reset_button": "Vrati na podrazumevanu listu" + }, + "import-status": "Status uvoza", + "in-progress": "Uvoz u toku: {{progress}}", + "successful": "Uvoz je uspešno završen." + }, + "include_note": { + "dialog_title": "Uključi belešku", + "label_note": "Beleška", + "placeholder_search": "pretraži belešku po njenom imenu", + "box_size_prompt": "Veličina kutije priložene beleške:", + "box_size_small": "mala (~ 10 redova)", + "box_size_medium": "srednja (~ 30 redova)", + "box_size_full": "puna (kutija prikazuje ceo tekst)", + "button_include": "Uključi belešku" + }, + "info": { + "modalTitle": "Informativna poruka", + "closeButton": "Zatvori", + "okButton": "U redu" + }, + "jump_to_note": { + "search_placeholder": "Pretraži belešku po njenom imenu ili unesi > za komande...", + "search_button": "Pretraga u punom tekstu Ctrl+Enter" + }, + "markdown_import": { + "dialog_title": "Uvoz za Markdown", + "modal_body_text": "Zbog Sandbox-a pretraživača nije moguće direktno učitati privremenu memoriju iz JavaScript-a. Molimo vas da nalepite Markdown za uvoz u tekstualno polje ispod i kliknete na dugme za uvoz", + "import_button": "Uvoz", + "import_success": "Markdown sadržaj je učitan u dokument." + }, + "move_to": { + "dialog_title": "Premesti beleške u ...", + "notes_to_move": "Beleške za premeštanje", + "target_parent_note": "Ciljana nadbeleška", + "search_placeholder": "potraži belešku po njenom imenu", + "move_button": "Pređi na izabranu belešku", + "error_no_path": "Nema putanje za premeštanje.", + "move_success_message": "Izabrane beleške su premeštene u " + }, + "note_type_chooser": { + "change_path_prompt": "Promenite gde će se napraviti nova beleška:", + "search_placeholder": "pretraži putanju po njenom imenu (podrazumevano ako je prazno)", + "modal_title": "Izaberite tip beleške", + "modal_body": "Izaberite tip beleške / šablon za novu belešku:", + "templates": "Šabloni" + }, + "password_not_set": { + "title": "Lozinka nije podešena", + "body1": "Zaštićene beleške su enkriptovane sa korisničkom lozinkom, ali lozinka još uvek nije podešena.", + "body2": "Za biste mogli da sačuvate beleške, kliknite ovde da otvorite dijalog sa Opcijama i podesite svoju lozinku." + }, + "prompt": { + "title": "Upit", + "ok": "U redu enter", + "defaultTitle": "Upit" + }, + "protected_session_password": { + "modal_title": "Zaštićena sesija", + "help_title": "Pomoć za Zaštićene beleške", + "close_label": "Zatvori", + "form_label": "Da biste nastavili sa traženom akcijom moraćete započeti zaštićenu sesiju tako što ćete uneti lozinku:", + "start_button": "Započni zaštićenu sesiju" + }, + "recent_changes": { + "title": "Nedavne promene", + "erase_notes_button": "Obriši izabrane beleške odmah", + "deleted_notes_message": "Obrisane beleške su uklonjene.", + "no_changes_message": "Još uvek nema izmena...", + "undelete_link": "poništi brisanje", + "confirm_undelete": "Da li želite da poništite brisanje ove beleške i njenih podbeleški?" + }, + "revisions": { + "note_revisions": "Revizije beleški", + "delete_all_revisions": "Obriši sve revizije ove beleške", + "delete_all_button": "Obriši sve revizije", + "help_title": "Pomoć za Revizije beleški", + "confirm_delete_all": "Da li želite da obrišete sve revizije ove beleške?", + "no_revisions": "Još uvek nema revizija za ovu belešku...", + "restore_button": "Vrati", + "confirm_restore": "Da li želite da vratite ovu reviziju? Ovo će prepisati trenutan naslov i sadržaj beleške sa ovom revizijom.", + "delete_button": "Obriši", + "confirm_delete": "Da li želite da obrišete ovu reviziju?", + "revisions_deleted": "Revizije beleške su obrisane.", + "revision_restored": "Revizija beleške je vraćena.", + "revision_deleted": "Revizija beleške je obrisana.", + "snapshot_interval": "Interval snimanja revizije beleške: {{seconds}}s.", + "maximum_revisions": "Ograničenje broja slika revizije beleške: {{number}}.", + "settings": "Podešavanja revizija beleški", + "download_button": "Preuzmi", + "mime": "MIME: ", + "file_size": "Veličina datoteke:", + "preview": "Pregled:", + "preview_not_available": "Pregled nije dostupan za ovaj tip beleške." + }, + "sort_child_notes": { + "sort_children_by": "Sortiranje podbeleški po...", + "sorting_criteria": "Kriterijum za sortiranje", + "title": "naslov", + "date_created": "datum kreiranja", + "date_modified": "datum izmene", + "sorting_direction": "Smer sortiranja", + "ascending": "uzlazni", + "descending": "silazni", + "folders": "Fascikle", + "sort_folders_at_top": "sortiraj fascikle na vrh", + "natural_sort": "Prirodno sortiranje", + "sort_with_respect_to_different_character_sorting": "sortiranje sa poštovanjem različitih pravila sortiranja karaktera i kolacija u različitim jezicima ili regionima.", + "natural_sort_language": "Jezik za prirodno sortiranje", + "the_language_code_for_natural_sort": "Kod jezika za prirodno sortiranje, npr. \"zh-CN\" za Kineski.", + "sort": "Sortiraj" + }, + "upload_attachments": { + "upload_attachments_to_note": "Otpremite priloge uz belešku", + "choose_files": "Izaberite datoteke", + "files_will_be_uploaded": "Datoteke će biti otpremljene kao prilozi u {{noteTitle}}", + "options": "Opcije", + "shrink_images": "Smanji slike", + "upload": "Otpremi", + "tooltip": "Ako je označeno, Trilium će pokušati da smanji otpremljene slike skaliranjem i optimizacijom što može uticati na kvalitet slike. Ako nije označeno, slike će biti otpremljene bez izmena." + }, + "attribute_detail": { + "attr_detail_title": "Naslov detalja atributa", + "close_button_title": "Otkaži izmene i zatvori", + "attr_is_owned_by": "Atribut je u vlasništvu", + "attr_name_title": "Naziv atributa može biti sastavljen samo od alfanumeričkih znakova, dvotačke i donje crte", + "name": "Naziv", + "value": "Vrednost", + "target_note_title": "Relacija je imenovana veza između izvorne beleške i ciljne beleške.", + "target_note": "Ciljna beleška", + "promoted_title": "Promovisani atribut je istaknut na belešci.", + "promoted": "Promovisan", + "promoted_alias_title": "Naziv koji će biti prikazan u korisničkom interfejsu promovisanih atributa.", + "promoted_alias": "Pseudonim", + "multiplicity_title": "Multiplicitet definiše koliko atributa sa istim nazivom se može napraviti - najviše 1 ili više od 1.", + "multiplicity": "Multiplicitet", + "single_value": "Jednostruka vrednost", + "multi_value": "Višestruka vrednost", + "label_type_title": "Tip oznake će pomoći Triliumu da izabere odgovarajući interfejs za unos vrednosti oznake.", + "label_type": "Tip", + "text": "Tekst", + "number": "Broj", + "boolean": "Boolean", + "date": "Datum", + "date_time": "Datum i vreme", + "time": "Vreme", + "url": "URL", + "precision_title": "Broj cifara posle zareza treba biti dostupan u interfejsu za postavljanje vrednosti.", + "precision": "Preciznost", + "digits": "cifre", + "inverse_relation_title": "Opciono podešavanje za definisanje kojoj relaciji je ova suprotna. Primer: Otac - Sin su inverzne relacije jedna drugoj.", + "inverse_relation": "Inverzna relacija", + "inheritable_title": "Atributi koji mogu da se nasleđuju će biti nasleđeni od strane svih potomaka unutar ovog stabla.", + "inheritable": "Nasledno", + "save_and_close": "Sačuvaj i zatvori Ctrl+Enter", + "delete": "Obriši", + "related_notes_title": "Druge beleške sa ovom oznakom", + "more_notes": "Još beleški", + "label": "Detalji oznake", + "label_definition": "Detalji definicije oznake", + "relation": "Detalji relacije", + "relation_definition": "Detalji definicije relacije", + "disable_versioning": "onemogućava auto-verzionisanje. Korisno za npr. velike, ali nebitne beleške - poput velikih JS biblioteka koje se koriste za skripte", + "calendar_root": "obeležava belešku koju treba koristiti kao osnova za dnevne beleške. Samo jedna beleška treba da bude označena kao takva.", + "archived": "beleške sa ovom oznakom neće biti podrazumevano vidljive u rezultatima pretrage (kao ni u dijalozima za Idi na, Dodaj link, itd.).", + "exclude_from_export": "beleške (sa svojim podstablom) neće biti uključene u bilo koji izvoz beleški", + "run": "definiše u kojim događajima se skripta pokreće. Moguće vrednosti su:\n
    \n
  • frontendStartup - kada se pokrene Trilium frontend (ili se osveži), ali ne na mobilnom uređaju.
  • \n
  • mobileStartup - kada se pokrene Trilium frontend (ili se osveži), na mobilnom uređaju..
  • \n
  • backendStartup - kada se Trilium backend pokrene
  • \n
  • hourly - pokreće se svaki sat. Može se koristiti dodatna oznaka runAtHour da se označi u kom satu.
  • \n
  • daily - pokreće se jednom dnevno
  • \n
", + "run_on_instance": "Definiše u kojoj instanci Trilium-a ovo treba da se pokreće. Podrazumevano podešavanje je na svim instancama.", + "run_at_hour": "U kom satu ovo treba da se pokreće. Treba se koristiti zajedno sa #run=hourly. Može biti definisano više puta za više pokretanja u toku dana.", + "disable_inclusion": "skripte sa ovom oznakom neće biti uključene u izvršavanju nadskripte.", + "sorted": "čuva podbeleške sortirane alfabetski po naslovu", + "sort_direction": "Uzlazno (podrazumevano) ili silazno", + "sort_folders_first": "Fascikle (beleške sa podbeleškama) treba da budu sortirane na vrhu", + "top": "zadrži datu belešku na vrhu njene nadbeleške (primenjuje se samo na sortiranim nadbeleškama)", + "hide_promoted_attributes": "Sakrij promovisane atribute na ovoj belešci", + "read_only": "uređivač je u režimu samo za čitanje. Radi samo za tekst i beleške sa kodom.", + "auto_read_only_disabled": "beleške sa tekstom/kodom se mogu automatski podesiti u režim za čitanje kada su prevelike. Ovo ponašanje možete onemogućiti pojedinačno za belešku dodavanjem ove oznake na belešku", + "app_css": "označava CSS beleške koje nisu učitane u Trilium aplikaciju i zbog toga se mogu koristiti za menjanje izgleda Triliuma.", + "app_theme": "označava CSS beleške koje su pune Trilium teme i stoga su dostupne u Trilium podešavanjima.", + "app_theme_base": "podesite na „sledeće“, „sledeće-svetlo“ ili „sledeće-tamno“ da biste koristili odgovarajuću TriliumNext temu (automatsku, svetlu ili tamnu) kao osnovu za prilagođenu temu, umesto podrazumevane teme.", + "css_class": "vrednost ove oznake se zatim dodaje kao CSS klasa čvoru koji predstavlja datu belešku u stablu. Ovo može biti korisno za napredno temiranje. Može se koristiti u šablonima beleški.", + "workspace": "označava ovu belešku kao radni prostor što omogućava lako podizanje", + "workspace_icon_class": "definiše CSS klasu ikone okvira koja će se koristiti u kartici kada se podigne na ovoj belešci", + "workspace_tab_background_color": "CSS boja korišćena u kartici beleške kada se prebaci na ovu belešku", + "workspace_calendar_root": "Definiše koren kalendara za svaki radni prostor", + "workspace_template": "Ova beleška će se pojaviti u izboru dostupnih šablona prilikom kreiranja nove beleške, ali samo kada se podigne u radni prostor koji sadrži ovaj šablon", + "search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške", + "workspace_search_home": "nove beleške o pretrazi biće kreirane kao podređeni delovi ove beleške kada se podignu na nekog pretka ove beleške iz radnog prostora", + "inbox": "podrazumevana lokacija u prijemnom sandučetu za nove beleške - kada kreirate belešku pomoću dugmeta „nova beleška“ u bočnoj traci, beleške će biti kreirane kao podbeleške u belešci označenoj sa oznakom #inbox.", + "workspace_inbox": "podrazumevana lokacija prijemnog sandučeta za nove beleške kada se prebace na nekog pretka ove beleške iz radnog prostora", + "sql_console_home": "podrazmevana lokacija beleški SQL konzole", + "bookmark_folder": "beleška sa ovom oznakom će se pojaviti u obeleživačima kao fascikla (omogućavajući pristup njenim podređenim fasciklama)", + "share_hidden_from_tree": "ova beleška je skrivena u levom navigacionom stablu, ali je i dalje dostupna preko svoje URL adrese", + "share_external_link": "beleška će služiti kao veza ka eksternoj veb stranici u stablu deljenja", + "share_alias": "definišite alias pomoću kog će beleška biti dostupna na https://your_trilium_host/share/[your_alias]", + "share_omit_default_css": "CSS kod podrazumevane stranice za deljenje će biti izostavljen. Koristite ga kada pravite opsežne promene stila.", + "share_root": "obeležava belešku koja se prikazuje na /share korenu.", + "share_description": "definišite tekst koji će se dodati HTML meta oznaci za opis", + "share_raw": "beleška će biti prikazana u svom sirovom (raw) formatu, bez HTML omotača", + "share_disallow_robot_indexing": "zabraniće robotsko indeksiranje ove beleške putem zaglavlja X-Robots-Tag: noindex", + "share_credentials": "potrebni su kredencijali za pristup ovoj deljenoj belešci. Očekuje se da vrednost bude u formatu „korisničko ime:lozinka“. Ne zaboravite da ovo označite kao nasledno da bi se primenilo na podbeleške/slike.", + "share_index": "beleška sa ovom oznakom će izlistati sve korene deljenih beleški", + "display_relations": "imena relacija razdvojenih zarezima koja treba da budu prikazana. Sva ostala će biti skrivena.", + "hide_relations": "imena relacija razdvojenih zarezima koja treba da budu skrivena. Sva ostala će biti prikazana.", + "title_template": "podrazumevani naslov beleški kreiranih kao deca ove beleške. Vrednost se procenjuje kao JavaScript string \n i stoga se može obogatiti dinamičkim sadržajem putem ubrizganih promenljivih now and parentNote. Primeri:\n \n
    \n
  • ${parentNote.getLabelValue('authorName')}'s literary works
  • \n
  • Log for ${now.format('YYYY-MM-DD HH:mm:ss')}
  • \n
\n \n Pogledati wiki sa detaljima, API dokumentacija za parentNote i now za detalje.", + "template": "Ova beleška će biti prikazana u izboru dostupnih šablona prilikom pravljenja nove beleške", + "toc": "#toc ili #toc=show će pristiliti Sadržaj (Table of Contents) da bude prikazan, #toc=hide prisiliti njegovo sakrivanje. Ako oznaka ne postoji, ponašanje će biti usklađeno sa globalnim podešavanjem", + "color": "definiše boju beleške u stablu beleški, linkovima itd. Koristite bilo koju važeću CSS vrednost boje kao što je „crvena“ ili #a13d5f", + "keyboard_shortcut": "Definiše prečicu na tastaturi koja će odmah preći na ovu belešku. Primer: „ctrl+alt+e“. Potrebno je ponovno učitavanje frontenda da bi promena stupila na snagu.", + "keep_current_hoisting": "Otvaranje ove veze neće promeniti podizanje čak i ako beleška nije prikazana u trenutno podignutom podstablu.", + "execute_button": "Naslov dugmeta koje će izvršiti trenutnu belešku sa kodom", + "execute_description": "Duži opis trenutne beleške sa kodom prikazan je zajedno sa dugmetom za izvršavanje", + "exclude_from_note_map": "Beleške sa ovom oznakom biće skrivene sa mape beleški", + "new_notes_on_top": "Nove beleške će biti napravljene na vrhu matične beleške, a ne na dnu.", + "hide_highlight_widget": "Sakrij vidžet sa listom istaknutih", + "run_on_note_creation": "izvršava se kada se beleška napravi na serverskoj strani. Koristite ovu relaciju ako želite da pokrenete skriptu za sve beleške napravljene u okviru određenog podstabla. U tom slučaju, kreirajte je na korenu beleške podstabla i učinite je naslednom. Nova beleška napravljena unutar podstabla (bilo koje dubine) pokrenuće skriptu.", + "run_on_child_note_creation": "izvršava se kada se napravi nova beleška ispod beleške gde je ova relacija definisana", + "run_on_note_title_change": "izvršava se kada se promeni naslov beleške (uključuje i pravljenje beleške)", + "run_on_note_content_change": "izvršava se kada se promeni sadržaj beleške (uključuje i pravljenje beleške).", + "run_on_note_change": "izvršava se kada se promeni beleška (uključuje i pravljenje beleške). Ne uključuje promene sadržaja", + "icon_class": "vrednost ove oznake se dodaje kao CSS klasa ikoni na stablu što može pomoći u vizuelnom razlikovanju beleški u stablu. Primer može biti bx bx-home - ikone su preuzete iz boxicons. Može se koristiti u šablonima beleški.", + "page_size": "broj stavki po stranici u listi beleški", + "custom_request_handler": "pogledajte Prilagođeni obrađivač zahteva", + "custom_resource_provider": "pogledajte Prilagođeni obrađivač zahteva", + "widget": "označava ovu belešku kao prilagođeni vidžet koji će biti dodat u stablo komponenti Trilijuma", + "run_on_note_deletion": "izvršava se kada se beleška briše", + "run_on_branch_creation": "izvršava se kada se pravi grana. Grana je veza između matične i podređene beleške i pravi se npr. prilikom kloniranja ili premeštanja beleške.", + "run_on_branch_change": "izvršava se kada se grana ažurira.", + "run_on_branch_deletion": "izvršava se kada se grana briše. Grana je veza između nadređene beleške i podređene beleške i briše se npr. prilikom premeštanja beleške (stara grana/veza se briše).", + "run_on_attribute_creation": "izvršava se kada se pravi novi atribut za belešku koji definiše ovu relaciju", + "run_on_attribute_change": " izvršava se kada se promeni atribut beleške koja definiše ovu relaciju. Ovo se pokreće i kada se atribut obriše", + "relation_template": "atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete, sadržaj i podstablo beleške će biti dodati instanci beleške ako je prazna. Pogledajte dokumentaciju za detalje.", + "inherit": "Atributi beleške će biti nasleđeni čak i bez odnosa roditelj-dete. Pogledajte relaciju šablona za sličan koncept. Pogledajte nasleđivanje atributa u dokumentaciji.", + "render_note": "Beleške tipa „render HTML note“ će biti prikazane korišćenjem beleške za kod (HTML ili skripte) i potrebno je pomoću ove relacije ukazati na to koja beleška treba da se prikaže", + "widget_relation": "meta ove relacije će biti izvršena i prikazana kao vidžet u bočnoj traci", + "share_css": "CSS napomena koja će biti ubrizgana na stranicu za deljenje. CSS napomena mora biti i u deljenom podstablu. Razmotrite i korišćenje „share_hidden_from_tree“ i „share_omit_default_css“.", + "share_js": "JavaScript beleška koja će biti ubrizgana na stranicu za deljenje. JS beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.", + "share_template": "Ugrađena JavaScript beleška koja će se koristiti kao šablon za prikazivanje deljene beleške. U slučaju neuspeha vraća se na podrazumevani šablon. Razmislite o korišćenju „share_hidden_from_tree“.", + "share_favicon": "Favicon beleška koju treba postaviti na deljenu stranicu. Obično je potrebno da je podesite da deli koren i učinite je naslednom. Favicon beleška takođe mora biti u deljenom podstablu. Razmislite o korišćenju „share_hidden_from_tree“.", + "is_owned_by_note": "je u vlasništvu beleške", + "other_notes_with_name": "Ostale beleške sa {{attributeType}} nazivom „{{attributeName}}“", + "and_more": "... i još {{count}}.", + "print_landscape": "Prilikom izvoza u PDF, menja orijentaciju stranice u pejzažnu umesto uspravne.", + "print_page_size": "Prilikom izvoza u PDF, menja veličinu stranice. Podržane vrednosti: A0, A1, A2, A3, A4, A5, A6, Legal, Letter, Tabloid, Ledger.", + "color_type": "Boja" + }, + "attribute_editor": { + "help_text_body1": "Da biste dodali oznaku, samo unesite npr. #rock ili ako želite da dodate i vrednost, onda npr. #year = 2020", + "help_text_body2": "Za relaciju, unesite ~author = @ što bi trebalo da otvori automatsko dovršavanje gde možete potražiti željenu belešku.", + "help_text_body3": "Alternativno, možete dodati oznaku i relaciju pomoću dugmeta + sa desne strane.", + "save_attributes": "Sačuvaj atribute ", + "add_a_new_attribute": "Dodajte novi atribut", + "add_new_label": "Dodajte novu oznaku ", + "add_new_relation": "Dodajte novu relaciju ", + "add_new_label_definition": "Dodajte novu definiciju oznake", + "add_new_relation_definition": "Dodajte novu definiciju relacije", + "placeholder": "Ovde unesite oznake i relacije" + }, + "abstract_bulk_action": { + "remove_this_search_action": "Ukloni ovu radnju pretrage" + }, + "execute_script": { + "execute_script": "Izvrši skriptu", + "help_text": "Možete izvršiti jednostavne skripte na podudarnim beleškama.", + "example_1": "Na primer, da biste dodali string u naslov beleške, koristite ovu malu skriptu:", + "example_2": "Složeniji primer bi bio brisanje svih atributa podudarnih beleški:" + }, + "add_label": { + "add_label": "Dodaj oznaku", + "label_name_placeholder": "ime oznake", + "label_name_title": "Alfanumerički znakovi, donja crta i dvotačka su dozvoljeni znakovi.", + "to_value": "za vrednost", + "new_value_placeholder": "nova vrednost", + "help_text": "Na svim podudarnim beleškama:", + "help_text_item1": "dodajte datu oznaku ako beleška još uvek nema jednu", + "help_text_item2": "ili izmenite vrednost postojeće oznake", + "help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti." + }, + "delete_label": { + "delete_label": "Obriši oznaku", + "label_name_placeholder": "ime oznake", + "label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi." + }, + "rename_label": { + "rename_label": "Preimenuj oznaku", + "rename_label_from": "Preimenuj oznaku iz", + "old_name_placeholder": "stari naziv", + "to": "U", + "new_name_placeholder": "novi naziv", + "name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi." + }, + "update_label_value": { + "update_label_value": "Ažuriraj vrednost oznake", + "label_name_placeholder": "ime oznake", + "label_name_title": "Alfanumerički znakovi, donja crtica i dvotačka su dozvoljeni znakovi.", + "to_value": "u vrednost", + "new_value_placeholder": "nova vrednost", + "help_text": "Na svim podudarnim beleškama, promenite vrednost postojeće oznake.", + "help_text_note": "Takođe možete pozvati ovu metodu bez vrednosti, u tom slučaju će oznaka biti dodeljena belešci bez vrednosti." + }, + "delete_note": { + "delete_note": "Obriši belešku", + "delete_matched_notes": "Obriši podudarne beleške", + "delete_matched_notes_description": "Ovo će obrisati podudarne beleške.", + "undelete_notes_instruction": "Nakon brisanja, moguće ga je poništiti iz dijaloga Nedavne izmene." + } } diff --git a/apps/client/src/translations/tr/translation.json b/apps/client/src/translations/tr/translation.json index d03e85496c..84c574a4d0 100644 --- a/apps/client/src/translations/tr/translation.json +++ b/apps/client/src/translations/tr/translation.json @@ -5,25 +5,32 @@ "db_version": "Veritabanı versiyonu:", "title": "Trilium Notes Hakkında", "sync_version": "Eşleştirme versiyonu:", - "data_directory": "Veri dizini:" + "data_directory": "Veri dizini:", + "build_date": "Derleme tarihi:", + "build_revision": "Derleme revizyonu:" }, "branch_prefix": { "save": "Kaydet", "edit_branch_prefix": "Dalın önekini düzenle", "prefix": "Önek: ", - "branch_prefix_saved": "Dal öneki kaydedildi." + "branch_prefix_saved": "Dal öneki kaydedildi.", + "edit_branch_prefix_multiple": "{{count}} dal için dal ön ekini düzenle", + "help_on_tree_prefix": "Ağaç ön eki hakkında yardım", + "branch_prefix_saved_multiple": "Dal ön eki, {{count}} dal için kaydedildi.", + "affected_branches": "Etkilenen dal sayısı ({{count}}):" }, "delete_notes": { "close": "Kapat", "delete_notes_preview": "Not önizlemesini sil", - "delete_all_clones_description": "Tüm klonları da sil (son değişikliklerden geri alınabilir)" + "delete_all_clones_description": "Tüm klonları da sil (son değişikliklerden geri alınabilir)", + "erase_notes_description": "Normal (yazılımsal) silme işlemi, notları yalnızca silinmiş olarak işaretler ve belirli bir süre içinde (son değişiklikler iletişim kutusunda) geri alınabilir. Bu seçeneği işaretlemek, notları hemen siler ve notların geri alınması mümkün olmaz." }, "export": { "close": "Kapat" }, "import": { "chooseImportFile": "İçe aktarım dosyası", - "importDescription": "Seçilen dosya(lar) alt not olarak içe aktarılacaktır" + "importDescription": "Seçilen dosya(lar)ın içeriği, alt not(lar) olarak şuraya içe aktarılacaktır" }, "info": { "closeButton": "Kapat" @@ -34,21 +41,23 @@ "toast": { "critical-error": { "title": "Kritik hata", - "message": "İstemci uygulamasının başlatılmasını engelleyen kritik bir hata meydana geldi\n\n{{message}}\n\nBu muhtemelen bir betiğin beklenmedik şekilde başarısız olmasından kaynaklanıyor. Uygulamayı güvenli modda başlatarak sorunu ele almayı deneyin." + "message": "İstemci uygulamasının başlamasını engelleyen kritik bir hata oluştu:\n\n{{message}}\n\nBunun nedeni büyük olasılıkla bir komut dosyasının beklenmedik bir şekilde başarısız olmasıdır. Uygulamayı güvenli modda başlatmayı ve sorunu gidermeyi deneyin." }, "widget-error": { "title": "Bir widget başlatılamadı", - "message-unknown": "Bilinmeyen widget aşağıdaki sebeple başlatılamadı\n\n{{message}}" + "message-unknown": "Bilinmeyen bir widget aşağıdaki sebeple başlatılamadı\n\n{{message}}", + "message-custom": "ID'si \"{{id}}\" ve başlığı \"{{title}}\" olan nottan alınan özel bileşen şu sebepten başlatılamadı:\n\n{{message}}" }, "bundle-error": { - "title": "Özel bir betik yüklenemedi" + "title": "Özel bir betik yüklenemedi", + "message": "ID'si \"{{id}}\" ve başlığı \"{{title}}\" olan nottan alınan komut dosyası şunun nedeniyle yürütülemedi:\n\n{{message}}" } }, "add_link": { "add_link": "Bağlantı ekle", "help_on_links": "Bağlantılar konusunda yardım", "note": "Not", - "search_note": "isimle not ara", + "search_note": "notu adına göre ara", "link_title_mirrors": "bağlantı adı notun şu anki adıyla aynı", "link_title_arbitrary": "bağlantı adı isteğe bağlı olarak değiştirilebilir", "link_title": "Bağlantı adı", @@ -85,12 +94,7 @@ "cancel": "İptal", "ok": "OK", "are_you_sure_remove_note": "\"{{title}}\" notunu ilişki haritasından kaldırmak istediğinize emin misiniz?. ", - "also_delete_note": "Notu da sil" - }, - "ai_llm": { - "n_notes_queued": "{{ count }} not dizinleme için sıraya alındı", - "n_notes_queued_plural": "{{ count }} not dizinleme için sıraya alındı", - "notes_indexed": "{{ count }} not dizinlendi", - "notes_indexed_plural": "{{ count }} not dizinlendi" + "also_delete_note": "Notu da sil", + "if_you_dont_check": "Bunu işaretlemezseniz, not yalnızca ilişki haritasından kaldırılacaktır." } } diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json index 767bb8ffc2..62c68997db 100644 --- a/apps/client/src/translations/tw/translation.json +++ b/apps/client/src/translations/tw/translation.json @@ -162,7 +162,8 @@ "inPageSearch": "頁面內搜尋", "title": "列表", "newTabNoteLink": "在新分頁開啟筆記連結", - "newTabWithActivationNoteLink": "在新分頁開啟並切換至筆記連結" + "newTabWithActivationNoteLink": "在新分頁開啟並切換至筆記連結", + "editShortcuts": "編輯鍵盤快捷鍵" }, "import": { "importIntoNote": "匯入至筆記", @@ -732,9 +733,9 @@ "zoom_out_title": "縮小" }, "zpetne_odkazy": { - "backlink": "{{count}} 個反連結", - "backlinks": "{{count}} 個反連結", - "relation": "關聯" + "relation": "關聯", + "backlink_one": "{{count}} 個反連結", + "backlink_other": "{{count}} 個反連結" }, "mobile_detail_menu": { "insert_child_note": "插入子筆記", @@ -761,7 +762,6 @@ "grid": "網格", "list": "列表", "collapse_all_notes": "收摺所有筆記", - "expand_all_children": "展開所有子項", "collapse": "收摺", "expand": "展開", "invalid_view_type": "無效的查看類型 '{{type}}'", @@ -771,7 +771,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": "今天還沒有編輯過的筆記...", @@ -980,7 +984,9 @@ "placeholder": "在這裡輸入您的程式碼筆記內容…" }, "editable_text": { - "placeholder": "在這裡輸入您的筆記內容…" + "placeholder": "在這裡輸入您的筆記內容…", + "auto-detect-language": "自動檢測", + "keeps-crashing": "編輯元件持續發生崩潰。請嘗試重新啟動 Trilium。若問題仍存在,請考慮提交錯誤報告。" }, "empty": { "open_note_instruction": "透過在下面的輸入框中輸入筆記標題或在樹中選擇筆記來打開筆記。", @@ -1148,7 +1154,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 快捷鍵", @@ -1422,7 +1431,7 @@ "import-into-note": "匯入至筆記", "apply-bulk-actions": "套用批次操作", "converted-to-attachments": "{{count}} 個筆記已被轉換為附件。", - "convert-to-attachment-confirm": "確定要將所選的筆記轉換為其父級筆記的附件嗎?", + "convert-to-attachment-confirm": "確定要將所選的筆記轉換為其父級筆記的附件嗎?此操作僅適用於圖像筆記,其他筆記將被跳過。", "duplicate": "複製副本", "open-in-popup": "快速編輯", "archive": "封存", @@ -1510,7 +1519,8 @@ "refresh-saved-search-results": "重新整理儲存的搜尋結果", "create-child-note": "建立子筆記", "unhoist": "取消聚焦", - "toggle-sidebar": "切換側邊欄" + "toggle-sidebar": "切換側邊欄", + "dropping-not-allowed": "不允許移動筆記至此處。" }, "title_bar_buttons": { "window-on-top": "保持此視窗置頂" @@ -1612,9 +1622,6 @@ "move-to-available-launchers": "移動至可用啟動器", "duplicate-launcher": "複製啟動器 " }, - "editable-text": { - "auto-detect-language": "自動檢測" - }, "highlighting": { "description": "控制文字筆記程式碼區塊中的語法高亮,程式碼筆記不會受到影響。", "color-scheme": "配色方案", @@ -1654,7 +1661,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": "複製引用到剪貼簿", @@ -1664,7 +1672,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": "在另一個頁面分割中打開筆記" }, "zen_mode": { "button_exit": "退出禪模式" @@ -1788,9 +1797,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 +2096,14 @@ "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": "切換至完整編輯器" } } diff --git a/apps/client/src/translations/uk/translation.json b/apps/client/src/translations/uk/translation.json index dac2f920ca..38b44a263a 100644 --- a/apps/client/src/translations/uk/translation.json +++ b/apps/client/src/translations/uk/translation.json @@ -130,9 +130,6 @@ "move-to-available-launchers": "Перейти до доступних лаунчерів", "duplicate-launcher": "Дублікат програми запуску " }, - "editable-text": { - "auto-detect-language": "Автовизначено" - }, "highlighting": { "color-scheme": "Схема кольорів", "title": "Блоки коду", @@ -839,9 +836,10 @@ "zoom_out_title": "Зменшити масштаб" }, "zpetne_odkazy": { - "backlink": "{{count}} Зворотне посилання", - "backlinks": "{{count}} Зворотні посилання", - "relation": "зв'язок" + "relation": "зв'язок", + "backlink_one": "{{count}} Зворотне посилання", + "backlink_few": "{{count}} Зворотні посилання", + "backlink_many": "{{count}} Зворотні посилання" }, "mobile_detail_menu": { "insert_child_note": "Вставити дочірню нотатку", @@ -867,7 +865,6 @@ "grid": "Сітка", "list": "Список", "collapse_all_notes": "Згорнути всі нотатки", - "expand_all_children": "Розгорнути всі дочірні", "collapse": "Згорнути", "expand": "Розгорнути", "book_properties": "Властивості Колекції", @@ -1076,7 +1073,8 @@ "placeholder": "Введіть тут вміст вашої нотатки з кодом..." }, "editable_text": { - "placeholder": "Введіть тут вміст вашої нотатки..." + "placeholder": "Введіть тут вміст вашої нотатки...", + "auto-detect-language": "Автовизначено" }, "empty": { "open_note_instruction": "Відкрийте нотатку, ввівши її заголовок в поле нижче, або виберіть нотатку в дереві.", @@ -1351,13 +1349,7 @@ "indexing_stopped": "Індексацію зупинено", "indexing_in_progress": "Триває індексація...", "last_indexed": "Остання індексація", - "n_notes_queued_0": "{{ count }} нотатка в черзі на індексацію", - "n_notes_queued_1": "{{ count }} нотатки в черзі на індексацію", - "n_notes_queued_2": "{{ count }} нотаток в черзі на індексацію", "note_chat": "Нотатка Чат", - "notes_indexed_0": "{{ count }} нотатка індексовано", - "notes_indexed_1": "{{ count }} нотатки індексовано", - "notes_indexed_2": "{{ count }} нотаток індексовано", "sources": "Джерела", "start_indexing": "Почати індексацію", "use_advanced_context": "Використовувати розширений контекст", diff --git a/apps/client/src/translations/vi/translation.json b/apps/client/src/translations/vi/translation.json index 77f4550678..022262c9f1 100644 --- a/apps/client/src/translations/vi/translation.json +++ b/apps/client/src/translations/vi/translation.json @@ -12,7 +12,8 @@ "add_link": { "add_link": "Thêm liên kết", "button_add_link": "Thêm liên kết", - "help_on_links": "Trợ giúp về các liên kết" + "help_on_links": "Trợ giúp về các liên kết", + "link_title": "Đề mục liên kết" }, "bulk_actions": { "other": "Khác" @@ -30,7 +31,9 @@ "cancel": "Huỷ" }, "export": { - "close": "Đóng" + "close": "Đóng", + "export": "Xuất", + "choose_export_type": "Xin hãy chọn cách xuất trước" }, "help": { "other": "Khác", @@ -98,5 +101,11 @@ }, "abstract_search_option": { "remove_this_search_option": "Xoá lựa chọn tìm kiếm này" + }, + "add_relation": { + "to": "tới" + }, + "abstract_bulk_action": { + "remove_this_search_action": "Xoá hành động tìm kiếm này" } } diff --git a/apps/client/src/types.d.ts b/apps/client/src/types.d.ts index c386a67f0b..7128ea5d80 100644 --- a/apps/client/src/types.d.ts +++ b/apps/client/src/types.d.ts @@ -64,6 +64,11 @@ declare global { EXCALIDRAW_ASSET_PATH?: string; } + interface WindowEventMap { + "note-ready": Event; + "note-load-progress": CustomEvent<{ progress: number }>; + } + interface AutoCompleteConfig { appendTo?: HTMLElement | null; hint?: boolean; diff --git a/apps/client/src/utils/debouncer.ts b/apps/client/src/utils/debouncer.ts new file mode 100644 index 0000000000..9057d039fe --- /dev/null +++ b/apps/client/src/utils/debouncer.ts @@ -0,0 +1,35 @@ +export type DebouncerCallback = (value: T) => void; + +export default class Debouncer { + + private debounceInterval: number; + private callback: DebouncerCallback; + private lastValue: T | undefined; + private timeoutId: any | null = null; + + constructor(debounceInterval: number, onUpdate: DebouncerCallback) { + this.debounceInterval = debounceInterval; + this.callback = onUpdate; + } + + updateValue(value: T) { + this.lastValue = value; + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId); + } + this.timeoutId = setTimeout(this.reportUpdate.bind(this), this.debounceInterval); + } + + destroy() { + if (this.timeoutId !== null) { + this.reportUpdate(); + clearTimeout(this.timeoutId); + } + } + + private reportUpdate() { + if (this.lastValue !== undefined) { + this.callback(this.lastValue); + } + } +} \ No newline at end of file diff --git a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx index efc60482e9..65743984e9 100644 --- a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx +++ b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx @@ -18,6 +18,8 @@ import froca from "../services/froca"; import NoteLink from "./react/NoteLink"; import RawHtml from "./react/RawHtml"; import { ViewTypeOptions } from "./collections/interface"; +import attributes from "../services/attributes"; +import LoadResults from "../services/load_results"; export interface FloatingButtonContext { parentComponent: Component; @@ -64,7 +66,15 @@ export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [ RelationMapButtons, ExportImageButtons, Backlinks -] +]; + +/** + * Floating buttons that should be hidden in popup editor (Quick edit). + */ +export const POPUP_HIDDEN_FLOATING_BUTTONS: FloatingButtonsList = [ + InAppHelpButton, + ToggleReadOnlyButton +]; function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) { const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode; @@ -102,7 +112,7 @@ function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingBut function EditButton({ note, noteContext }: FloatingButtonContext) { const [animationClass, setAnimationClass] = useState(""); const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext); - + const isReadOnlyInfoBarDismissed = false; // TODO useEffect(() => { @@ -302,13 +312,18 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) { let [ popupOpen, setPopupOpen ] = useState(false); const backlinksContainerRef = useRef(null); - useEffect(() => { + function refresh() { if (!isDefaultViewMode) return; server.get(`note-map/${note.noteId}/backlink-count`).then(resp => { setBacklinkCount(resp.count); }); - }, [ note ]); + } + + useEffect(() => refresh(), [ note ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (needsRefresh(note, loadResults)) refresh(); + }); // Determine the max height of the container. const { windowHeight } = useWindowSize(); @@ -333,18 +348,18 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) { {popupOpen && (
- +
)} ); } -function BacklinksList({ noteId }: { noteId: string }) { +function BacklinksList({ note }: { note: FNote }) { const [ backlinks, setBacklinks ] = useState([]); - useEffect(() => { - server.get(`note-map/${noteId}/backlinks`).then(async (backlinks) => { + function refresh() { + server.get(`note-map/${note.noteId}/backlinks`).then(async (backlinks) => { // prefetch all const noteIds = backlinks .filter(bl => "noteId" in bl) @@ -352,7 +367,12 @@ function BacklinksList({ noteId }: { noteId: string }) { await froca.getNotes(noteIds); setBacklinks(backlinks); }); - }, [ noteId ]); + } + + useEffect(() => refresh(), [ note ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (needsRefresh(note, loadResults)) refresh(); + }); return backlinks.map(backlink => (
@@ -372,3 +392,9 @@ function BacklinksList({ noteId }: { noteId: string }) {
)); } + +function needsRefresh(note: FNote, loadResults: LoadResults) { + return loadResults.getAttributeRows().some(attr => + attr.type === "relation" && + attributes.isAffecting(attr, note)); +} diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 0482d0ddb3..272385d258 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -28,8 +28,9 @@ export default function NoteDetail() { const { note, type, mime, noteContext, parentComponent } = useNoteInfo(); const { ntxId, viewScope } = noteContext ?? {}; const isFullHeight = checkFullHeight(noteContext, type); - const noteTypesToRender = useRef<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({}); + const [ noteTypesToRender, setNoteTypesToRender ] = useState<{ [ key in ExtendedNoteType ]?: (props: TypeWidgetProps) => VNode }>({}); const [ activeNoteType, setActiveNoteType ] = useState(); + const widgetRequestId = useRef(0); const props: TypeWidgetProps = { note: note!, @@ -38,19 +39,28 @@ export default function NoteDetail() { parentComponent, noteContext }; + useEffect(() => { if (!type) return; + const requestId = ++widgetRequestId.current; - if (!noteTypesToRender.current[type]) { + if (!noteTypesToRender[type]) { getCorrespondingWidget(type).then((el) => { if (!el) return; - noteTypesToRender.current[type] = el; + + // Ignore stale requests + if (requestId !== widgetRequestId.current) return; + + setNoteTypesToRender(prev => ({ + ...prev, + [type]: el + })); setActiveNoteType(type); }); } else { setActiveNoteType(type); } - }, [ note, viewScope, type ]); + }, [ note, viewScope, type, noteTypesToRender ]); // Detect note type changes. useTriliumEvent("entitiesReloaded", async ({ loadResults }) => { @@ -95,9 +105,11 @@ export default function NoteDetail() { }); // Automatically focus the editor. - useTriliumEvent("activeNoteChanged", () => { - // Restore focus to the editor when switching tabs, but only if the note tree is not already focused. - if (!document.activeElement?.classList.contains("fancytree-title")) { + useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => { + if (eventNtxId != ntxId) return; + // Restore focus to the editor when switching tabs, + // but only if the note tree and the note panel (e.g., note title or note detail) are not focused. + if (!document.activeElement?.classList.contains("fancytree-title") && !parentComponent.$widget[0].closest(".note-split")?.contains(document.activeElement)) { parentComponent.triggerCommand("focusOnDetail", { ntxId }); } }); @@ -113,11 +125,14 @@ export default function NoteDetail() { useEffect(() => { if (!isElectron()) return; const { ipcRenderer } = dynamicRequire("electron"); - const listener = () => { - toast.closePersistent("printing"); + const onPrintProgress = (_e: any, { progress, action }: { progress: number, action: "printing" | "exporting_pdf" }) => showToast(action, progress); + const onPrintDone = () => toast.closePersistent("printing"); + ipcRenderer.on("print-progress", onPrintProgress); + ipcRenderer.on("print-done", onPrintDone); + return () => { + ipcRenderer.off("print-progress", onPrintProgress); + ipcRenderer.off("print-done", onPrintDone); }; - ipcRenderer.on("print-done", listener); - return () => ipcRenderer.off("print-done", listener); }, []); useTriliumEvent("executeInActiveNoteDetailWidget", ({ callback }) => { @@ -139,11 +154,7 @@ export default function NoteDetail() { useTriliumEvent("printActiveNote", () => { if (!noteContext?.isActive() || !note) return; - toast.showPersistent({ - icon: "bx bx-loader-circle bx-spin", - message: t("note_detail.printing"), - id: "printing" - }); + showToast("printing"); if (isElectron()) { const { ipcRenderer } = dynamicRequire("electron"); @@ -162,6 +173,10 @@ export default function NoteDetail() { return; } + iframe.contentWindow.addEventListener("note-load-progress", (e) => { + showToast("printing", e.detail.progress); + }); + iframe.contentWindow.addEventListener("note-ready", () => { toast.closePersistent("printing"); iframe.contentWindow?.print(); @@ -173,11 +188,7 @@ export default function NoteDetail() { useTriliumEvent("exportAsPdf", () => { if (!noteContext?.isActive() || !note) return; - toast.showPersistent({ - icon: "bx bx-loader-circle bx-spin", - message: t("note_detail.printing_pdf"), - id: "printing" - }); + showToast("exporting_pdf"); const { ipcRenderer } = dynamicRequire("electron"); ipcRenderer.send("export-as-pdf", { @@ -193,7 +204,7 @@ export default function NoteDetail() { ref={containerRef} class={`note-detail ${isFullHeight ? "full-height" : ""}`} > - {Object.entries(noteTypesToRender.current).map(([ itemType, Element ]) => { + {Object.entries(noteTypesToRender).map(([ itemType, Element ]) => { return label { + user-select: none; + font-weight: bold; + vertical-align: middle; +} +.promoted-attribute-cell > * { + display: table-cell; + padding: 1px 0; +} + +.promoted-attribute-cell div.input-group { + margin-inline-start: 10px; + display: flex; + min-height: 40px; +} +.promoted-attribute-cell strong { + word-break:keep-all; + white-space: nowrap; +} + +.promoted-attribute-cell input[type="checkbox"] { + width: 22px !important; + flex-grow: 0; + width: unset; +} + +/* Restore default apperance */ +.promoted-attribute-cell input[type="number"], +.promoted-attribute-cell input[type="checkbox"] { + appearance: auto; +} + +.promoted-attribute-cell input[type="color"] { + width: 24px; + height: 24px; + margin-top: 2px; + appearance: none; + padding: 0; + border: 0; + outline: none; + border-radius: 25% !important; +} + +.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.promoted-attribute-cell input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 25%; +} + +.promoted-attribute-label-number input { + text-align: right; + width: 120px; +} + +.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] { + position: relative; + opacity: 0.5; +} + +.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after { + content: ""; + position: absolute; + top: 10px; + inset-inline-start: 0px; + inset-inline-end: 0; + height: 2px; + background: rgba(0, 0, 0, 0.5); + transform: rotate(45deg); + pointer-events: none; +} \ No newline at end of file diff --git a/apps/client/src/widgets/PromotedAttributes.tsx b/apps/client/src/widgets/PromotedAttributes.tsx new file mode 100644 index 0000000000..e25420f68c --- /dev/null +++ b/apps/client/src/widgets/PromotedAttributes.tsx @@ -0,0 +1,464 @@ +import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks"; +import "./PromotedAttributes.css"; +import { useNoteContext, useNoteLabel, useTriliumEvent, useUniqueName } from "./react/hooks"; +import { Attribute } from "../services/attribute_parser"; +import FAttribute from "../entities/fattribute"; +import clsx from "clsx"; +import { t } from "../services/i18n"; +import { DefinitionObject, extractAttributeDefinitionTypeAndName, LabelType } from "../services/promoted_attribute_definition_parser"; +import server from "../services/server"; +import FNote from "../entities/fnote"; +import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact"; +import NoteAutocomplete from "./react/NoteAutocomplete"; +import ws from "../services/ws"; +import { UpdateAttributeResponse } from "@triliumnext/commons"; +import attributes from "../services/attributes"; +import debounce from "../services/debounce"; + +interface Cell { + uniqueId: string; + definitionAttr: FAttribute; + definition: DefinitionObject; + valueAttr: Attribute; + valueName: string; +} + +interface CellProps { + note: FNote; + componentId: string; + cell: Cell, + cells: Cell[], + shouldFocus: boolean; + setCells: Dispatch>; + setCellToFocus(cell: Cell): void; +} + +type OnChangeEventData = TargetedEvent | InputEvent | JQuery.TriggeredEvent; +type OnChangeListener = (e: OnChangeEventData) => Promise; + +export default function PromotedAttributes() { + const { note, componentId } = useNoteContext(); + const [ cells, setCells ] = usePromotedAttributeData(note, componentId); + const [ cellToFocus, setCellToFocus ] = useState(); + + return ( +
+ {cells && cells.length > 0 &&
+ {note && cells?.map(cell => )} +
} +
+ ); +} + +/** + * Handles the individual cells (instances for promoted attributes including empty attributes). Promoted attributes with "multiple" multiplicity will have + * each value represented as a separate cell. + * + * The cells are returned as a state since they can also be altered internally if needed, for example to add a new empty cell. + */ +function usePromotedAttributeData(note: FNote | null | undefined, componentId: string): [ Cell[] | undefined, Dispatch> ] { + const [ viewType ] = useNoteLabel(note, "viewType"); + const [ cells, setCells ] = useState(); + + function refresh() { + if (!note || viewType === "table") { + setCells([]); + return; + } + const promotedDefAttrs = note.getPromotedDefinitionAttributes(); + const ownedAttributes = note.getOwnedAttributes(); + // attrs are not resorted if position changes after the initial load + // promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs + // the order of attributes is important as well + ownedAttributes.sort((a, b) => a.position - b.position); + + const cells: Cell[] = []; + for (const definitionAttr of promotedDefAttrs) { + const [ valueType, valueName ] = extractAttributeDefinitionTypeAndName(definitionAttr.name); + + let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[]; + + if (valueAttrs.length === 0) { + valueAttrs.push({ + attributeId: "", + type: valueType, + name: valueName, + value: "" + }); + } + + if (definitionAttr.getDefinition().multiplicity === "single") { + valueAttrs = valueAttrs.slice(0, 1); + } + + for (const [ i, valueAttr ] of valueAttrs.entries()) { + const definition = definitionAttr.getDefinition(); + + // if not owned, we'll force creation of a new attribute instead of updating the inherited one + if (valueAttr.noteId !== note.noteId) { + valueAttr.attributeId = ""; + } + + const uniqueId = `${note.noteId}-${valueAttr.name}-${i}`; + cells.push({ definitionAttr, definition, valueAttr, valueName, uniqueId }); + } + } + setCells(cells); + } + + useEffect(refresh, [ note, viewType ]); + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getAttributeRows(componentId).find((attr) => attributes.isAffecting(attr, note))) { + refresh(); + } + }); + + return [ cells, setCells ]; +} + +function PromotedAttributeCell(props: CellProps) { + const { valueName, valueAttr, definition } = props.cell; + const inputId = useUniqueName(`value-${valueAttr.name}`); + + useEffect(() => { + if (!props.shouldFocus) return; + const inputEl = document.getElementById(inputId); + if (inputEl) { + inputEl.focus(); + } + }, [ props.shouldFocus ]); + + let correspondingInput: ComponentChild; + let className: string | undefined; + switch (valueAttr.type) { + case "label": + correspondingInput = ; + className = `promoted-attribute-label-${definition.labelType}`; + break; + case "relation": + correspondingInput = ; + className = "promoted-attribute-relation"; + break; + default: + ws.logError(t(`promoted_attributes.unknown_attribute_type`, { type: valueAttr.type })); + break; + } + + return ( +
+ {definition.labelType !== "boolean" && } + {correspondingInput} + +
+ ) +} + +const LABEL_MAPPINGS: Record = { + text: "text", + number: "number", + boolean: "checkbox", + date: "date", + datetime: "datetime-local", + time: "time", + color: "hidden", // handled separately. + url: "url" +}; + +function LabelInput({ inputId, ...props }: CellProps & { inputId: string }) { + const { valueName, valueAttr, definition, definitionAttr } = props.cell; + const onChangeListener = buildPromotedAttributeLabelChangedListener({...props}); + const extraInputProps: InputHTMLAttributes = {}; + + useEffect(() => { + if (definition.labelType === "text") { + const el = document.getElementById(inputId); + if (el) { + setupTextLabelAutocomplete(el as HTMLInputElement, valueAttr, onChangeListener); + } + } + }, [ inputId, valueAttr, onChangeListener ]); + + switch (definition.labelType) { + case "number": { + let step = 1; + for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) { + step /= 10; + } + extraInputProps.step = step; + break; + } + case "url": { + extraInputProps.placeholder = t("promoted_attributes.url_placeholder"); + break; + } + } + + const inputNode = ; + + if (definition.labelType === "boolean") { + return <> +
+ +
+ + + } else { + return ( +
+ {inputNode} + { definition.labelType === "color" && } + { definition.labelType === "url" && ( + { + const inputEl = document.getElementById(inputId) as HTMLInputElement | null; + const url = inputEl?.value; + if (url) { + window.open(url, "_blank"); + } + }} + /> + )} +
+ ); + } +} + + +// We insert a separate input since the color input does not support empty value. +// This is a workaround to allow clearing the color input. +function ColorPicker({ cell, onChange, inputId }: CellProps & { + onChange: (e: TargetedEvent) => Promise, + inputId: string; +}) { + const defaultColor = "#ffffff"; + const colorInputRef = useRef(null); + return ( + <> + + { + // Indicate to the user the color was reset. + if (colorInputRef.current) { + colorInputRef.current.value = defaultColor; + } + + // Trigger the actual attribute change by injecting it into the hidden field. + const inputEl = document.getElementById(inputId) as HTMLInputElement | null; + if (!inputEl) return; + inputEl.value = ""; + onChange({ + ...e, + target: inputEl + } as unknown as TargetedInputEvent); + }} + /> + + ) +} + +function RelationInput({ inputId, ...props }: CellProps & { inputId: string }) { + return ( + { + const { note, cell, componentId, setCells } = props; + await updateAttribute(note, cell, componentId, value, setCells); + }} + /> + ) +} + +function MultiplicityCell({ cell, cells, setCells, setCellToFocus, note, componentId }: CellProps) { + return (cell.definition.multiplicity === "multi" && + + { + const index = cells.indexOf(cell); + const newCell: Cell = { + ...cell, + valueAttr: { + attributeId: "", + type: cell.valueAttr.type, + name: cell.valueName, + value: "" + } + }; + setCells([ + ...cells.slice(0, index + 1), + newCell, + ...cells.slice(index + 1) + ]); + setCellToFocus(newCell); + }} + />{' '} + { + // Remove the attribute from the server if it exists. + const { attributeId, type } = cell.valueAttr; + const valueName = cell.valueName; + if (attributeId) { + await server.remove(`notes/${note.noteId}/attributes/${attributeId}`, componentId); + } + + const index = cells.indexOf(cell); + const isLastOneOfType = cells.filter(c => c.valueAttr.type === type && c.valueAttr.name === valueName).length < 2; + const newOnesToInsert: Cell[] = []; + if (isLastOneOfType) { + newOnesToInsert.push({ + ...cell, + valueAttr: { + attributeId: "", + type: cell.valueAttr.type, + name: cell.valueName, + value: "" + } + }) + } + setCells(cells.toSpliced(index, 1, ...newOnesToInsert)); + }} + /> + + ) +} + +function PromotedActionButton({ icon, title, onClick }: { + icon: string, + title: string, + onClick: MouseEventHandler +}) { + return ( + + ) +} + +function InputButton({ icon, className, title, onClick }: { + icon: string; + className?: string; + title: string; + onClick: MouseEventHandler; +}) { + return ( + + ) +} + +function setupTextLabelAutocomplete(el: HTMLInputElement, valueAttr: Attribute, onChangeListener: OnChangeListener) { + // no need to await for this, can be done asynchronously + const $input = $(el); + server.get(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributeValues) => { + if (_attributeValues.length === 0) { + return; + } + + const attributeValues = _attributeValues.map((attribute) => ({ value: attribute })); + + $input.autocomplete( + { + appendTo: document.querySelector("body"), + hint: false, + autoselect: false, + openOnFocus: true, + minLength: 0, + tabAutocomplete: false + }, + [ + { + displayKey: "value", + source: function (term, cb) { + term = term.toLowerCase(); + + const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term)); + + cb(filtered); + } + } + ] + ); + + $input.off("autocomplete:selected"); + $input.on("autocomplete:selected", onChangeListener); + }); +} + +function buildPromotedAttributeLabelChangedListener({ note, cell, componentId, setCells }: CellProps): OnChangeListener { + async function onChange(e: OnChangeEventData) { + const inputEl = e.target as HTMLInputElement; + let value: string; + + if (inputEl.type === "checkbox") { + value = inputEl.checked ? "true" : "false"; + } else { + value = inputEl.value; + } + + await updateAttribute(note, cell, componentId, value, setCells); + } + + return debounce(onChange, 250); +} + +async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch>) { + const { attributeId } = await server.put( + `notes/${note.noteId}/attribute`, + { + attributeId: cell.valueAttr.attributeId, + type: cell.valueAttr.type, + name: cell.valueName, + value: value || "" + }, + componentId + ); + setCells(prev => + prev?.map(c => + c.uniqueId === cell.uniqueId + ? { ...c, valueAttr: { + ...c.valueAttr, + attributeId, + value + } } + : c + ) + ); +} diff --git a/apps/client/src/widgets/ReadOnlyNoteInfoBar.css b/apps/client/src/widgets/ReadOnlyNoteInfoBar.css index 82ecaa726f..bf222a611a 100644 --- a/apps/client/src/widgets/ReadOnlyNoteInfoBar.css +++ b/apps/client/src/widgets/ReadOnlyNoteInfoBar.css @@ -16,4 +16,5 @@ body.zen div.read-only-note-info-bar-widget { :root div.read-only-note-info-bar-widget button { white-space: nowrap; padding: 2px 8px; + flex-shrink: 0; } \ No newline at end of file diff --git a/apps/client/src/widgets/ReadOnlyNoteInfoBar.tsx b/apps/client/src/widgets/ReadOnlyNoteInfoBar.tsx index f10939d471..af305fc1cc 100644 --- a/apps/client/src/widgets/ReadOnlyNoteInfoBar.tsx +++ b/apps/client/src/widgets/ReadOnlyNoteInfoBar.tsx @@ -3,34 +3,33 @@ import { t } from "../services/i18n"; import { useIsNoteReadOnly, useNoteContext, useTriliumEvent } from "./react/hooks" import Button from "./react/Button"; import InfoBar from "./react/InfoBar"; +import HelpButton from "./react/HelpButton"; export default function ReadOnlyNoteInfoBar(props: {}) { - const {note, noteContext} = useNoteContext(); - const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext); + const { note, noteContext } = useNoteContext(); + const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); const isExplicitReadOnly = note?.isLabelTruthy("readOnly"); - return - -
- {(isExplicitReadOnly) ? ( -
{t("read-only-info.read-only-note")}
- ) : ( -
- {t("read-only-info.auto-read-only-note")} -   - - - {t("read-only-info.auto-read-only-learn-more")} - -
- )} - -
+
+ ); +} diff --git a/apps/client/src/widgets/Toast.css b/apps/client/src/widgets/Toast.css new file mode 100644 index 0000000000..519e3b62d1 --- /dev/null +++ b/apps/client/src/widgets/Toast.css @@ -0,0 +1,69 @@ +#toast-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: absolute; + width: 100%; + top: 20px; + pointer-events: none; + contain: none; +} + +.toast { + --bs-toast-bg: var(--accented-background-color); + --bs-toast-color: var(--main-text-color); + z-index: 9999999999 !important; + pointer-events: all; + overflow: hidden; +} + +.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; +} + +.toast { + .toast-buttons { + padding: 0 1em 1em 1em; + display: flex; + gap: 1em; + justify-content: space-between; + } + + .toast-progress { + position: absolute; + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + background-color: var(--toast-text-color) !important; + height: 4px; + transition: width 0.1s linear; + } +} + diff --git a/apps/client/src/widgets/Toast.tsx b/apps/client/src/widgets/Toast.tsx new file mode 100644 index 0000000000..9630836e1b --- /dev/null +++ b/apps/client/src/widgets/Toast.tsx @@ -0,0 +1,75 @@ +import "./Toast.css"; + +import clsx from "clsx"; +import { useEffect } from "preact/hooks"; + +import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast"; +import Icon from "./react/Icon"; +import { RawHtmlBlock } from "./react/RawHtml"; +import Button from "./react/Button"; + +export default function ToastContainer() { + return ( +
+ {toasts.value.map(toast => )} +
+ ) +} + +function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOptionsWithRequiredId) { + // Autohide. + useEffect(() => { + if (!timeout || timeout <= 0) return; + const timerId = setTimeout(() => removeToastFromStore(id), timeout); + return () => clearTimeout(timerId); + }, [ id, timeout ]); + + function dismissToast() { + removeToastFromStore(id); + } + + const closeButton = ( + - - - - - - - -
- - - - - -
- - -
-
-`; - -const DAYS_OF_WEEK = [ - t("calendar.sun"), - t("calendar.mon"), - t("calendar.tue"), - t("calendar.wed"), - t("calendar.thu"), - t("calendar.fri"), - t("calendar.sat") -]; - -interface DateNotesForMonth { - [date: string]: string; -} - -interface WeekCalculationOptions { - firstWeekType: number; - minDaysInFirstWeek: number; -} - -export default class CalendarWidget extends RightDropdownButtonWidget { - private $month!: JQuery; - private $weekHeader!: JQuery; - private $monthSelect!: JQuery; - private $yearSelect!: JQuery; - private $next!: JQuery; - private $previous!: JQuery; - private $nextYear!: JQuery; - private $previousYear!: JQuery; - private monthDropdown!: Dropdown; - // stored in ISO 1–7 - private firstDayOfWeekISO!: number; - private weekCalculationOptions!: WeekCalculationOptions; - private activeDate: Dayjs | null = null; - private todaysDate!: Dayjs; - private date!: Dayjs; - private weekNoteEnable: boolean = false; - private weekNotes: string[] = []; - - constructor(title: string = "", icon: string = "") { - super(title, icon, DROPDOWN_TPL); - } - - doRender() { - super.doRender(); - - this.$month = this.$dropdownContent.find('[data-calendar-area="month"]'); - this.$weekHeader = this.$dropdownContent.find(".calendar-week"); - - this.manageFirstDayOfWeek(); - this.initWeekCalculation(); - - // Month navigation - this.$monthSelect = this.$dropdownContent.find('[data-calendar-input="month"]'); - this.$monthSelect.on("show.bs.dropdown", (e) => { - // Don't trigger dropdownShown() at widget level when the month selection dropdown is shown, since it would cause a redundant refresh. - e.stopPropagation(); - }); - this.monthDropdown = Dropdown.getOrCreateInstance(this.$monthSelect[0]); - this.$dropdownContent.find('[data-calendar-input="month-list"] button').on("click", (e) => { - const target = e.target as HTMLElement; - const value = target.dataset.value; - if (value) { - this.date = this.date.month(parseInt(value)); - this.createMonth(); - } - }); - - this.$next = this.$dropdownContent.find('[data-calendar-toggle="next"]'); - this.$next.on("click", () => { - this.date = this.date.add(1, 'month'); - this.createMonth(); - }); - this.$previous = this.$dropdownContent.find('[data-calendar-toggle="previous"]'); - this.$previous.on("click", () => { - this.date = this.date.subtract(1, 'month'); - this.createMonth(); - }); - - // Year navigation - this.$yearSelect = this.$dropdownContent.find('[data-calendar-input="year"]'); - this.$yearSelect.on("input", (e) => { - const target = e.target as HTMLInputElement; - this.date = this.date.year(parseInt(target.value)); - this.createMonth(); - }); - - this.$nextYear = this.$dropdownContent.find('[data-calendar-toggle="nextYear"]'); - this.$nextYear.on("click", () => { - this.date = this.date.add(1, 'year'); - this.createMonth(); - }); - - this.$previousYear = this.$dropdownContent.find('[data-calendar-toggle="previousYear"]'); - this.$previousYear.on("click", () => { - this.date = this.date.subtract(1, 'year'); - this.createMonth(); - }); - - // Date click - this.$dropdownContent.on("click", ".calendar-date", async (ev) => { - const date = $(ev.target).closest(".calendar-date").attr("data-calendar-date"); - if (date) { - const note = await dateNoteService.getDayNote(date); - if (note) { - appContext.tabManager.getActiveContext()?.setNote(note.noteId); - this.dropdown?.hide(); - } else { - toastService.showError(t("calendar.cannot_find_day_note")); - } - } - ev.stopPropagation(); - }); - - // Week click - this.$dropdownContent.on("click", ".calendar-week-number", async (ev) => { - if (!this.weekNoteEnable) { - return; - } - - const week = $(ev.target).closest(".calendar-week-number").attr("data-calendar-week-number"); - - if (week) { - const note = await dateNoteService.getWeekNote(week); - - if (note) { - appContext.tabManager.getActiveContext()?.setNote(note.noteId); - this.dropdown?.hide(); - } else { - toastService.showError(t("calendar.cannot_find_week_note")); - } - } - - ev.stopPropagation(); - }); - - // Handle click events for the entire calendar widget - this.$dropdownContent.on("click", (e) => { - const $target = $(e.target); - - // Keep dropdown open when clicking on month select button or year selector area - if ($target.closest('.btn.dropdown-toggle.select-button').length || - $target.closest('.calendar-year-selector').length) { - e.stopPropagation(); - return; - } - - // Hide dropdown for all other cases - this.monthDropdown.hide(); - // Prevent dismissing the calendar popup by clicking on an empty space inside it. - e.stopPropagation(); - }); - } - - private async getWeekNoteEnable() { - const noteId = await server.get(`search/${encodeURIComponent('#calendarRoot')}`); - if (noteId.length === 0) { - this.weekNoteEnable = false; - return; - } - const noteAttributes = await server.get(`notes/${noteId}/attributes`); - this.weekNoteEnable = noteAttributes.some(a => a.name === 'enableWeekNote'); - } - - // Store firstDayOfWeek as ISO (1–7) - manageFirstDayOfWeek() { - const rawFirstDayOfWeek = options.getInt("firstDayOfWeek") || 0; - this.firstDayOfWeekISO = rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek; - - let localeDaysOfWeek = [...DAYS_OF_WEEK]; - const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek); - localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted]; - this.$weekHeader.html(localeDaysOfWeek.map((el) => `${el}`).join('')); - } - - initWeekCalculation() { - this.weekCalculationOptions = { - firstWeekType: options.getInt("firstWeekOfYear") || 0, - minDaysInFirstWeek: options.getInt("minDaysInFirstWeek") || 4 - }; - } - - getWeekStartDate(date: Dayjs): Dayjs { - const currentISO = date.isoWeekday(); - const diff = (currentISO - this.firstDayOfWeekISO + 7) % 7; - return date.clone().subtract(diff, "day").startOf("day"); - } - - getWeekNumber(date: Dayjs): number { - const weekStart = this.getWeekStartDate(date); - return weekStart.isoWeek(); - } - - async dropdownShown() { - await this.getWeekNoteEnable(); - this.weekNotes = await server.get(`attribute-values/weekNote`); - this.init(appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote") ?? null); - } - - init(activeDate: string | null) { - this.activeDate = activeDate ? dayjs(`${activeDate}T12:00:00`) : null; - this.todaysDate = dayjs(); - this.date = dayjs(this.activeDate || this.todaysDate).startOf('month'); - this.createMonth(); - } - - createDay(dateNotesForMonth: DateNotesForMonth, num: number) { - const $newDay = $("") - .addClass("calendar-date") - .attr("data-calendar-date", this.date.local().format('YYYY-MM-DD')); - const $date = $("").html(String(num)); - const dateNoteId = dateNotesForMonth[this.date.local().format('YYYY-MM-DD')]; - - if (dateNoteId) { - $newDay.addClass("calendar-date-exists").attr("data-href", `#root/${dateNoteId}`); - } - - if (this.date.isSame(this.activeDate, 'day')) $newDay.addClass("calendar-date-active"); - if (this.date.isSame(this.todaysDate, 'day')) $newDay.addClass("calendar-date-today"); - - $newDay.append($date); - return $newDay; - } - - createWeekNumber(weekNumber: number) { - const weekNoteId = this.date.local().format('YYYY-') + 'W' + String(weekNumber).padStart(2, '0'); - let $newWeekNumber; - - if (this.weekNoteEnable) { - $newWeekNumber = $("").addClass("calendar-date"); - if (this.weekNotes.includes(weekNoteId)) { - $newWeekNumber.addClass("calendar-date-exists").attr("data-href", `#root/${weekNoteId}`); - } - } else { - $newWeekNumber = $("").addClass("calendar-week-number-disabled"); - } - - $newWeekNumber.addClass("calendar-week-number").attr("data-calendar-week-number", weekNoteId); - $newWeekNumber.append($("").html(String(weekNumber))); - return $newWeekNumber; - } - - // Use isoWeekday() consistently - private getPrevMonthDays(firstDayISO: number): { weekNumber: number, dates: Dayjs[] } { - const prevMonthLastDay = this.date.subtract(1, 'month').endOf('month'); - const daysToAdd = (firstDayISO - this.firstDayOfWeekISO + 7) % 7; - const dates: Dayjs[] = []; - - const firstDay = this.date.startOf('month'); - const weekNumber = this.getWeekNumber(firstDay); - - // Get dates from previous month - for (let i = daysToAdd - 1; i >= 0; i--) { - dates.push(prevMonthLastDay.subtract(i, 'day')); - } - - return { weekNumber, dates }; - } - - private getNextMonthDays(lastDayISO: number): Dayjs[] { - const nextMonthFirstDay = this.date.add(1, 'month').startOf('month'); - const dates: Dayjs[] = []; - - const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1; // ISO wrap - const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7; - - for (let i = 0; i < daysToAdd; i++) { - dates.push(nextMonthFirstDay.add(i, 'day')); - } - return dates; - } - - async createMonth() { - const month = this.date.format('YYYY-MM'); - const dateNotesForMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${month}`); - - this.$month.empty(); - - const firstDay = this.date.startOf('month'); - const firstDayISO = firstDay.isoWeekday(); - - // Previous month filler - if (firstDayISO !== this.firstDayOfWeekISO) { - const { weekNumber, dates } = this.getPrevMonthDays(firstDayISO); - const prevMonth = this.date.subtract(1, 'month').format('YYYY-MM'); - const dateNotesForPrevMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${prevMonth}`); - - const $weekNumber = this.createWeekNumber(weekNumber); - this.$month.append($weekNumber); - - dates.forEach(date => { - const tempDate = this.date; - this.date = date; - const $day = this.createDay(dateNotesForPrevMonth, date.date()); - $day.addClass('calendar-date-prev-month'); - this.$month.append($day); - this.date = tempDate; - }); - } - - const currentMonth = this.date.month(); - - // Main month - while (this.date.month() === currentMonth) { - const weekNumber = this.getWeekNumber(this.date); - if (this.date.isoWeekday() === this.firstDayOfWeekISO) { - const $weekNumber = this.createWeekNumber(weekNumber); - this.$month.append($weekNumber); - } - - const $day = this.createDay(dateNotesForMonth, this.date.date()); - this.$month.append($day); - this.date = this.date.add(1, 'day'); - } - // while loop trips over and day is at 30/31, bring it back - this.date = this.date.startOf('month').subtract(1, 'month'); - - // Add dates from next month - const lastDayOfMonth = this.date.endOf('month'); - const lastDayISO = lastDayOfMonth.isoWeekday(); - const lastDayOfUserWeek = ((this.firstDayOfWeekISO + 6 - 1) % 7) + 1; - - if (lastDayISO !== lastDayOfUserWeek) { - const dates = this.getNextMonthDays(lastDayISO); - const nextMonth = this.date.add(1, 'month').format('YYYY-MM'); - const dateNotesForNextMonth: DateNotesForMonth = await server.get(`special-notes/notes-for-month/${nextMonth}`); - - dates.forEach(date => { - const tempDate = this.date; - this.date = date; - const $day = this.createDay(dateNotesForNextMonth, date.date()); - $day.addClass('calendar-date-next-month'); - this.$month.append($day); - this.date = tempDate; - }); - } - - this.$monthSelect.text(MONTHS[this.date.month()]); - this.$yearSelect.val(this.date.year()); - } - - async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - const WEEK_OPTIONS: (keyof OptionDefinitions)[] = [ - "firstDayOfWeek", - "firstWeekOfYear", - "minDaysInFirstWeek", - ]; - if (!WEEK_OPTIONS.some(opt => loadResults.getOptionNames().includes(opt))) { - return; - } - - this.manageFirstDayOfWeek(); - this.initWeekCalculation(); - this.createMonth(); - } -} diff --git a/apps/client/src/widgets/buttons/command_button.ts b/apps/client/src/widgets/buttons/command_button.ts deleted file mode 100644 index 49b147d355..0000000000 --- a/apps/client/src/widgets/buttons/command_button.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ActionKeyboardShortcut } from "@triliumnext/commons"; -import type { CommandNames } from "../../components/app_context.js"; -import keyboardActionsService from "../../services/keyboard_actions.js"; -import AbstractButtonWidget, { type AbstractButtonWidgetSettings } from "./abstract_button.js"; -import type { ButtonNoteIdProvider } from "./button_from_note.js"; - -let actions: ActionKeyboardShortcut[]; - -keyboardActionsService.getActions().then((as) => (actions = as)); - -// TODO: Is this actually used? -export type ClickHandler = (widget: CommandButtonWidget, e: JQuery.ClickEvent) => void; -type CommandOrCallback = CommandNames | (() => CommandNames); - -interface CommandButtonWidgetSettings extends AbstractButtonWidgetSettings { - command?: CommandOrCallback; - onClick?: ClickHandler; - buttonNoteIdProvider?: ButtonNoteIdProvider | null; -} - -export default class CommandButtonWidget extends AbstractButtonWidget { - constructor() { - super(); - this.settings = { - titlePlacement: "right", - title: null, - icon: null, - onContextMenu: null - }; - } - - doRender() { - super.doRender(); - - if (this.settings.command) { - this.$widget.on("click", () => { - this.tooltip.hide(); - - if (this._command) { - this.triggerCommand(this._command); - } - }); - } else { - console.warn(`Button widget '${this.componentId}' has no defined command`, this.settings); - } - } - - getTitle() { - const title = super.getTitle(); - - const action = actions.find((act) => act.actionName === this._command); - - if (action?.effectiveShortcuts && action.effectiveShortcuts.length > 0) { - return `${title} (${action.effectiveShortcuts.join(", ")})`; - } else { - return title; - } - } - - onClick(handler: ClickHandler) { - this.settings.onClick = handler; - return this; - } - - command(command: CommandOrCallback) { - this.settings.command = command; - return this; - } - - get _command() { - return typeof this.settings.command === "function" ? this.settings.command() : this.settings.command; - } -} diff --git a/apps/client/src/widgets/buttons/global_menu.tsx b/apps/client/src/widgets/buttons/global_menu.tsx index 9b36d83a54..12cd870eba 100644 --- a/apps/client/src/widgets/buttons/global_menu.tsx +++ b/apps/client/src/widgets/buttons/global_menu.tsx @@ -26,7 +26,8 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: const isVerticalLayout = !isHorizontalLayout; const parentComponent = useContext(ParentComponent); const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus(); - + const isMobileLocal = isMobile(); + return ( } } noDropdownListStyle + onShown={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.add("show", "global-menu-cover") : undefined} + onHidden={isMobileLocal ? () => document.getElementById("context-menu-cover")?.classList.remove("show", "global-menu-cover") : undefined} > @@ -58,14 +61,14 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: - + {isUpdateAvailable && <> window.open("https://github.com/TriliumNext/Trilium/releases/latest")} icon="bx bx-download" text={t("global_menu.download-update", {latestVersion})} /> } - + {!isElectron() && } ) @@ -221,9 +224,15 @@ function useTriliumUpdateStatus() { async function updateVersionStatus() { const RELEASES_API_URL = "https://api.github.com/repos/TriliumNext/Trilium/releases/latest"; - const resp = await fetch(RELEASES_API_URL); - const data = await resp.json(); - const latestVersion = data?.tag_name?.substring(1); + let latestVersion: string | undefined = undefined; + try { + const resp = await fetch(RELEASES_API_URL); + const data = await resp.json(); + latestVersion = data?.tag_name?.substring(1); + } catch (e) { + console.warn("Unable to fetch latest version info from GitHub releases API", e); + } + setLatestVersion(latestVersion); } diff --git a/apps/client/src/widgets/buttons/history_navigation.ts b/apps/client/src/widgets/buttons/history_navigation.ts deleted file mode 100644 index 74eaf6acc4..0000000000 --- a/apps/client/src/widgets/buttons/history_navigation.ts +++ /dev/null @@ -1,90 +0,0 @@ -import utils from "../../services/utils.js"; -import contextMenu, { MenuCommandItem } from "../../menus/context_menu.js"; -import treeService from "../../services/tree.js"; -import ButtonFromNoteWidget from "./button_from_note.js"; -import type FNote from "../../entities/fnote.js"; -import type { CommandNames } from "../../components/app_context.js"; -import type { WebContents } from "electron"; -import link from "../../services/link.js"; - -export default class HistoryNavigationButton extends ButtonFromNoteWidget { - private webContents?: WebContents; - - constructor(launcherNote: FNote, command: string) { - super(); - - this.title(() => launcherNote.title) - .icon(() => launcherNote.getIcon()) - .command(() => command as CommandNames) - .titlePlacement("right") - .buttonNoteIdProvider(() => launcherNote.noteId) - .onContextMenu((e) => { if (e) this.showContextMenu(e); }) - .class("launcher-button"); - } - - doRender() { - super.doRender(); - - if (utils.isElectron()) { - this.webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents(); - - // without this, the history is preserved across frontend reloads - this.webContents?.clearHistory(); - - this.refresh(); - } - } - - async showContextMenu(e: JQuery.ContextMenuEvent) { - e.preventDefault(); - - if (!this.webContents || this.webContents.navigationHistory.length() < 2) { - return; - } - - let items: MenuCommandItem[] = []; - - const history = this.webContents.navigationHistory.getAllEntries(); - const activeIndex = this.webContents.navigationHistory.getActiveIndex(); - - for (const idx in history) { - const { notePath } = link.parseNavigationStateFromUrl(history[idx].url); - if (!notePath) continue; - - const title = await treeService.getNotePathTitle(notePath); - - items.push({ - title, - command: idx, - uiIcon: - parseInt(idx) === activeIndex - ? "bx bx-radio-circle-marked" // compare with type coercion! - : parseInt(idx) < activeIndex - ? "bx bx-left-arrow-alt" - : "bx bx-right-arrow-alt" - }); - } - - items.reverse(); - - if (items.length > 20) { - items = items.slice(0, 50); - } - - contextMenu.show({ - x: e.pageX, - y: e.pageY, - items, - selectMenuItemHandler: (item: MenuCommandItem) => { - if (item && item.command && this.webContents) { - const idx = parseInt(item.command, 10); - this.webContents.navigationHistory.goToIndex(idx); - } - } - }); - } - - activeNoteChangedEvent() { - this.refresh(); - } -} diff --git a/apps/client/src/widgets/buttons/launcher/note_launcher.ts b/apps/client/src/widgets/buttons/launcher/note_launcher.ts deleted file mode 100644 index 00ba956a1f..0000000000 --- a/apps/client/src/widgets/buttons/launcher/note_launcher.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { t } from "../../../services/i18n.js"; -import AbstractLauncher from "./abstract_launcher.js"; -import dialogService from "../../../services/dialog.js"; -import appContext from "../../../components/app_context.js"; -import utils from "../../../services/utils.js"; -import linkContextMenuService from "../../../menus/link_context_menu.js"; -import type FNote from "../../../entities/fnote.js"; - -// we're intentionally displaying the launcher title and icon instead of the target, -// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok), -// but on the launchpad you want them distinguishable. -// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad -// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons). -// The only downside is more work in setting up the typical case -// where you actually want to have both title and icon in sync, but for those cases there are bookmarks -export default class NoteLauncher extends AbstractLauncher { - constructor(launcherNote: FNote) { - super(launcherNote); - - this.title(() => this.launcherNote.title) - .icon(() => this.launcherNote.getIcon()) - .onClick((widget, evt) => this.launch(evt)) - .onAuxClick((widget, evt) => this.launch(evt)) - .onContextMenu(async (evt) => { - let targetNoteId = await Promise.resolve(this.getTargetNoteId()); - - if (!targetNoteId || !evt) { - return; - } - - const hoistedNoteId = this.getHoistedNoteId(); - - linkContextMenuService.openContextMenu(targetNoteId, evt, {}, hoistedNoteId); - }); - } - - async launch(evt?: JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent) { - // await because subclass overrides can be async - const targetNoteId = await this.getTargetNoteId(); - if (!targetNoteId || evt?.which === 3) { - return; - } - - const hoistedNoteId = await this.getHoistedNoteId(); - if (!hoistedNoteId) { - return; - } - - if (!evt) { - // keyboard shortcut - // TODO: Fix once tabManager is ported. - //@ts-ignore - await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId); - } else { - const ctrlKey = utils.isCtrlKey(evt); - const activate = evt.shiftKey ? true : false; - - if ((evt.which === 1 && ctrlKey) || evt.which === 2) { - // TODO: Fix once tabManager is ported. - //@ts-ignore - await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteId, activate); - } else { - // TODO: Fix once tabManager is ported. - //@ts-ignore - await appContext.tabManager.openInSameTab(targetNoteId, hoistedNoteId); - } - } - } - - getTargetNoteId(): void | string | Promise { - const targetNoteId = this.launcherNote.getRelationValue("target"); - - if (!targetNoteId) { - dialogService.info(t("note_launcher.this_launcher_doesnt_define_target_note")); - return; - } - - return targetNoteId; - } - - getHoistedNoteId() { - return this.launcherNote.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId; - } - - getTitle() { - const shortcuts = this.launcherNote - .getLabels("keyboardShortcut") - .map((l) => l.value) - .filter((v) => !!v) - .join(", "); - - let title = super.getTitle(); - if (shortcuts) { - title += ` (${shortcuts})`; - } - - return title; - } -} diff --git a/apps/client/src/widgets/buttons/launcher/script_launcher.ts b/apps/client/src/widgets/buttons/launcher/script_launcher.ts deleted file mode 100644 index 8a91e08b1f..0000000000 --- a/apps/client/src/widgets/buttons/launcher/script_launcher.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type FNote from "../../../entities/fnote.js"; -import AbstractLauncher from "./abstract_launcher.js"; - -export default class ScriptLauncher extends AbstractLauncher { - constructor(launcherNote: FNote) { - super(launcherNote); - - this.title(() => this.launcherNote.title) - .icon(() => this.launcherNote.getIcon()) - .onClick(() => this.launch()); - } - - async launch() { - if (this.launcherNote.isLabelTruthy("scriptInLauncherContent")) { - await this.launcherNote.executeScript(); - } else { - const script = await this.launcherNote.getRelationTarget("script"); - if (script) { - await script.executeScript(); - } - } - } -} diff --git a/apps/client/src/widgets/buttons/launcher/today_launcher.ts b/apps/client/src/widgets/buttons/launcher/today_launcher.ts deleted file mode 100644 index 7e203bb7b7..0000000000 --- a/apps/client/src/widgets/buttons/launcher/today_launcher.ts +++ /dev/null @@ -1,15 +0,0 @@ -import NoteLauncher from "./note_launcher.js"; -import dateNotesService from "../../../services/date_notes.js"; -import appContext from "../../../components/app_context.js"; - -export default class TodayLauncher extends NoteLauncher { - async getTargetNoteId() { - const todayNote = await dateNotesService.getTodayNote(); - - return todayNote?.noteId; - } - - getHoistedNoteId() { - return appContext.tabManager.getActiveContext()?.hoistedNoteId; - } -} diff --git a/apps/client/src/widgets/buttons/open_note_button_widget.ts b/apps/client/src/widgets/buttons/open_note_button_widget.ts deleted file mode 100644 index c0a4c63340..0000000000 --- a/apps/client/src/widgets/buttons/open_note_button_widget.ts +++ /dev/null @@ -1,49 +0,0 @@ -import OnClickButtonWidget from "./onclick_button.js"; -import linkContextMenuService from "../../menus/link_context_menu.js"; -import utils from "../../services/utils.js"; -import appContext from "../../components/app_context.js"; -import type FNote from "../../entities/fnote.js"; - -export default class OpenNoteButtonWidget extends OnClickButtonWidget { - - private noteToOpen: FNote; - - constructor(noteToOpen: FNote) { - super(); - - this.noteToOpen = noteToOpen; - - this.title(() => utils.escapeHtml(this.noteToOpen.title)) - .icon(() => this.noteToOpen.getIcon()) - .onClick((widget, evt) => this.launch(evt)) - .onAuxClick((widget, evt) => this.launch(evt)) - .onContextMenu((evt) => { - if (evt) { - linkContextMenuService.openContextMenu(this.noteToOpen.noteId, evt); - } - }); - } - - async launch(evt: JQuery.ClickEvent | JQuery.TriggeredEvent | JQuery.ContextMenuEvent) { - if (evt.which === 3) { - return; - } - const hoistedNoteId = this.getHoistedNoteId(); - const ctrlKey = utils.isCtrlKey(evt); - - if ((evt.which === 1 && ctrlKey) || evt.which === 2) { - const activate = evt.shiftKey ? true : false; - await appContext.tabManager.openInNewTab(this.noteToOpen.noteId, hoistedNoteId, activate); - } else { - await appContext.tabManager.openInSameTab(this.noteToOpen.noteId); - } - } - - getHoistedNoteId() { - return this.noteToOpen.getRelationValue("hoistedNote") || appContext.tabManager.getActiveContext()?.hoistedNoteId; - } - - initialRenderCompleteEvent() { - // we trigger refresh above - } -} diff --git a/apps/client/src/widgets/buttons/protected_session_status.ts b/apps/client/src/widgets/buttons/protected_session_status.ts deleted file mode 100644 index e5dde7d3d3..0000000000 --- a/apps/client/src/widgets/buttons/protected_session_status.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { t } from "../../services/i18n.js"; -import protectedSessionHolder from "../../services/protected_session_holder.js"; -import CommandButtonWidget from "./command_button.js"; - -export default class ProtectedSessionStatusWidget extends CommandButtonWidget { - constructor() { - super(); - - this.class("launcher-button"); - - this.settings.icon = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "bx-check-shield" : "bx-shield-quarter"); - - this.settings.title = () => (protectedSessionHolder.isProtectedSessionAvailable() ? t("protected_session_status.active") : t("protected_session_status.inactive")); - - this.settings.command = () => (protectedSessionHolder.isProtectedSessionAvailable() ? "leaveProtectedSession" : "enterProtectedSession"); - } - - protectedSessionStartedEvent() { - this.refreshIcon(); - } -} diff --git a/apps/client/src/widgets/buttons/right_dropdown_button.ts b/apps/client/src/widgets/buttons/right_dropdown_button.ts deleted file mode 100644 index 7c43f14af3..0000000000 --- a/apps/client/src/widgets/buttons/right_dropdown_button.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { handleRightToLeftPlacement } from "../../services/utils.js"; -import BasicWidget from "../basic_widget.js"; -import { Tooltip, Dropdown } from "bootstrap"; -type PopoverPlacement = Tooltip.PopoverPlacement; - -const TPL = /*html*/` - -`; - -export default class RightDropdownButtonWidget extends BasicWidget { - protected iconClass: string; - protected title: string; - protected dropdownTpl: string; - protected settings: { titlePlacement: PopoverPlacement }; - protected $dropdownMenu!: JQuery; - protected dropdown!: Dropdown; - protected $tooltip!: JQuery; - protected tooltip!: Tooltip; - public $dropdownContent!: JQuery; - - constructor(title: string, iconClass: string, dropdownTpl: string) { - super(); - - this.iconClass = iconClass; - this.title = title; - this.dropdownTpl = dropdownTpl; - - this.settings = { - titlePlacement: "right" - }; - } - - doRender() { - this.$widget = $(TPL); - this.$dropdownMenu = this.$widget.find(".dropdown-menu"); - this.dropdown = Dropdown.getOrCreateInstance(this.$widget.find("[data-bs-toggle='dropdown']")[0], { - popperConfig: { - placement: this.settings.titlePlacement, - } - }); - - this.$widget.attr("title", this.title); - this.tooltip = Tooltip.getOrCreateInstance(this.$widget[0], { - trigger: "hover", - placement: handleRightToLeftPlacement(this.settings.titlePlacement), - fallbackPlacements: [ handleRightToLeftPlacement(this.settings.titlePlacement) ] - }); - - this.$widget - .find(".right-dropdown-button") - .addClass(this.iconClass) - .on("click", () => this.tooltip.hide()); - - this.$widget.on("show.bs.dropdown", async () => { - await this.dropdownShown(); - - const rect = this.$dropdownMenu[0].getBoundingClientRect(); - const windowHeight = $(window).height() || 0; - const pixelsToBottom = windowHeight - rect.bottom; - - if (pixelsToBottom < 0) { - this.$dropdownMenu.css("top", pixelsToBottom); - } - }); - - this.$dropdownContent = $(this.dropdownTpl); - this.$widget.find(".dropdown-menu").append(this.$dropdownContent); - } - - // to be overridden - async dropdownShown(): Promise {} -} diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index 1d5a968106..3188a77ad7 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -1,19 +1,14 @@ import { allViewTypes, ViewModeMedia, ViewModeProps, ViewTypeOptions } from "./interface"; -import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useTriliumEvent } from "../react/hooks"; +import { useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent } from "../react/hooks"; import FNote from "../../entities/fnote"; import "./NoteList.css"; -import { ListView, GridView } from "./legacy/ListOrGridView"; import { useEffect, useRef, useState } from "preact/hooks"; -import GeoView from "./geomap"; import ViewModeStorage from "./view_mode_storage"; -import CalendarView from "./calendar"; -import TableView from "./table"; -import BoardView from "./board"; import { subscribeToMessages, unsubscribeToMessage as unsubscribeFromMessage } from "../../services/ws"; import { WebSocketMessage } from "@triliumnext/commons"; import froca from "../../services/froca"; -import PresentationView from "./presentation"; - +import { lazy, Suspense } from "preact/compat"; +import { VNode } from "preact"; interface NoteListProps { note: FNote | null | undefined; notePath: string | null | undefined; @@ -25,15 +20,44 @@ interface NoteListProps { media: ViewModeMedia; viewType: ViewTypeOptions | undefined; onReady?: () => void; + onProgressChanged?(progress: number): void; } -export default function NoteList(props: Pick) { - const { note, noteContext, notePath, ntxId } = useNoteContext(); +type LazyLoadedComponent = ((props: ViewModeProps) => VNode | undefined); +const ViewComponents: Record = { + list: { + normal: lazy(() => import("./legacy/ListOrGridView.js").then(i => i.ListView)), + print: lazy(() => import("./legacy/ListPrintView.js").then(i => i.ListPrintView)) + }, + grid: { + normal: lazy(() => import("./legacy/ListOrGridView.js").then(i => i.GridView)), + }, + geoMap: { + normal: lazy(() => import("./geomap/index.js")), + }, + calendar: { + normal: lazy(() => import("./calendar/index.js")) + }, + table: { + normal: lazy(() => import("./table/index.js")), + print: lazy(() => import("./table/TablePrintView.js")) + }, + board: { + normal: lazy(() => import("./board/index.js")) + }, + presentation: { + normal: lazy(() => import("./presentation/index.js")) + } +} + +export default function NoteList(props: Pick) { + const { note, noteContext, notePath, ntxId, viewScope } = useNoteContext(); const viewType = useNoteViewType(note); + const noteType = useNoteProperty(note, "type"); const [ enabled, setEnabled ] = useState(noteContext?.hasNoteList()); useEffect(() => { setEnabled(noteContext?.hasNoteList()); - }, [ noteContext, viewType ]) + }, [ note, noteContext, viewType, viewScope?.viewMode, noteType ]) return } @@ -42,7 +66,7 @@ export function SearchNoteList(props: Omit } -export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, ...restProps }: NoteListProps) { +export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId, onReady, onProgressChanged, ...restProps }: NoteListProps) { const widgetRef = useRef(null); const noteIds = useNoteIds(shouldEnable ? note : null, viewType, ntxId); const isFullHeight = (viewType && viewType !== "list" && viewType !== "grid"); @@ -85,40 +109,29 @@ export function CustomNoteList({ note, viewType, isEnabled: shouldEnable, notePa viewConfig: viewModeConfig.config, saveConfig: viewModeConfig.storeFn, onReady: onReady ?? (() => {}), + onProgressChanged: onProgressChanged ?? (() => {}), + ...restProps } } + const ComponentToRender = viewType && props && isEnabled && ( + props.media === "print" ? ViewComponents[viewType].print : ViewComponents[viewType].normal + ); + return (
- {props && isEnabled && ( + {ComponentToRender && props && (
- {getComponentByViewType(viewType, props)} + + +
)}
); } -function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps) { - switch (viewType) { - case "list": - return ; - case "grid": - return ; - case "geoMap": - return ; - case "calendar": - return - case "table": - return - case "board": - return - case "presentation": - return - } -} - export function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefined { const [ viewType ] = useNoteLabel(note, "viewType"); @@ -135,6 +148,7 @@ export function useNoteViewType(note?: FNote | null): ViewTypeOptions | undefine export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOptions | undefined, ntxId: string | null | undefined) { const [ noteIds, setNoteIds ] = useState([]); const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); + const directChildrenOnly = (viewType === "list" || viewType === "grid" || viewType === "table" || note?.type === "search"); async function refreshNoteIds() { if (!note) { @@ -145,7 +159,7 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt } async function getNoteIds(note: FNote) { - if (viewType === "list" || viewType === "grid" || viewType === "table" || note.type === "search") { + if (directChildrenOnly) { return await note.getChildNoteIdsWithArchiveFiltering(includeArchived); } else { return await note.getSubtreeNoteIds(includeArchived); @@ -153,7 +167,9 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt } // Refresh on note switch. - useEffect(() => { refreshNoteIds() }, [ note, includeArchived ]); + useEffect(() => { + refreshNoteIds() + }, [ note, includeArchived, directChildrenOnly ]); // Refresh on alterations to the note subtree. useTriliumEvent("entitiesReloaded", ({ loadResults }) => { diff --git a/apps/client/src/widgets/collections/board/context_menu.ts b/apps/client/src/widgets/collections/board/context_menu.ts index 0c818a1112..d7ae56a52c 100644 --- a/apps/client/src/widgets/collections/board/context_menu.ts +++ b/apps/client/src/widgets/collections/board/context_menu.ts @@ -1,9 +1,10 @@ import FNote from "../../../entities/fnote"; +import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker"; import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu"; import link_context_menu from "../../../menus/link_context_menu"; -import attributes from "../../../services/attributes"; import branches from "../../../services/branches"; import dialog from "../../../services/dialog"; +import { getArchiveMenuItem } from "../../../menus/context_menu_utils"; import { t } from "../../../services/i18n"; import Api from "./api"; @@ -40,18 +41,7 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo x: event.pageX, y: event.pageY, items: [ - ...link_context_menu.getItems(), - { kind: "separator" }, - { - title: t("board_view.move-to"), - uiIcon: "bx bx-transfer", - items: api.columns.map(columnToMoveTo => ({ - title: columnToMoveTo, - enabled: columnToMoveTo !== column, - handler: () => api.changeColumn(note.noteId, columnToMoveTo) - })), - }, - getArchiveMenuItem(note), + ...link_context_menu.getItems(event), { kind: "separator" }, { title: t("board_view.insert-above"), @@ -64,6 +54,17 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo handler: () => api.insertRowAtPosition(column, branchId, "after") }, { kind: "separator" }, + { + title: t("board_view.move-to"), + uiIcon: "bx bx-transfer", + items: api.columns.map(columnToMoveTo => ({ + title: columnToMoveTo, + enabled: columnToMoveTo !== column, + handler: () => api.changeColumn(note.noteId, columnToMoveTo) + })), + }, + { kind: "separator" }, + getArchiveMenuItem(note), { title: t("board_view.remove-from-board"), uiIcon: "bx bx-task-x", @@ -74,25 +75,13 @@ export function openNoteContextMenu(api: Api, event: ContextMenuEvent, note: FNo uiIcon: "bx bx-trash", handler: () => branches.deleteNotes([ branchId ], false, false) }, + { kind: "separator" }, + { + kind: "custom", + componentFn: () => NoteColorPicker({note}) + } ], - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, note.noteId), + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, event, note.noteId), }); } -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") - } - } - } -} diff --git a/apps/client/src/widgets/collections/board/data.spec.ts b/apps/client/src/widgets/collections/board/data.spec.ts index 357aea6162..83a36ac0e6 100644 --- a/apps/client/src/widgets/collections/board/data.spec.ts +++ b/apps/client/src/widgets/collections/board/data.spec.ts @@ -24,7 +24,7 @@ describe("Board data", () => { parentNoteId: "note1" }); froca.branches["note1_note2"] = branch; - froca.getNoteFromCache("note1").addChild("note2", "note1_note2", false); + froca.getNoteFromCache("note1")!.addChild("note2", "note1_note2", false); const data = await getBoardData(parentNote, "status", {}, false); const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId); expect(noteIds.length).toBe(3); diff --git a/apps/client/src/widgets/collections/board/index.css b/apps/client/src/widgets/collections/board/index.css index 8b6ab9180d..28cc98a2c8 100644 --- a/apps/client/src/widgets/collections/board/index.css +++ b/apps/client/src/widgets/collections/board/index.css @@ -2,6 +2,7 @@ position: relative; height: 100%; user-select: none; + overflow-x: auto; --card-font-size: 0.9em; --card-line-height: 1.2; diff --git a/apps/client/src/widgets/collections/board/index.tsx b/apps/client/src/widgets/collections/board/index.tsx index 7b939224d5..a50213a317 100644 --- a/apps/client/src/widgets/collections/board/index.tsx +++ b/apps/client/src/widgets/collections/board/index.tsx @@ -164,12 +164,12 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC onWheel={onWheelHorizontalScroll} > -
- {byColumn && columns?.map((column, index) => ( + {columns.map((column, index) => ( <> {columnDropPosition === index && (
@@ -191,7 +191,7 @@ export default function BoardView({ note: parentNote, noteIds, viewConfig, saveC )} -
+
}
) @@ -204,8 +204,19 @@ function AddNewColumn({ api, isInRelationMode }: { api: BoardApi, isInRelationMo setIsCreatingNewColumn(true); }, []); + const keydownCallback = useCallback((e: KeyboardEvent) => { + if (e.key === "Enter") { + setIsCreatingNewColumn(true); + } + }, []); + return ( -
+
{!isCreatingNewColumn ? <> {" "} diff --git a/apps/client/src/widgets/collections/calendar/context_menu.ts b/apps/client/src/widgets/collections/calendar/context_menu.ts index b15ba376d1..5b157567ed 100644 --- a/apps/client/src/widgets/collections/calendar/context_menu.ts +++ b/apps/client/src/widgets/collections/calendar/context_menu.ts @@ -1,11 +1,12 @@ +import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker"; import FNote from "../../../entities/fnote"; import contextMenu, { ContextMenuEvent } from "../../../menus/context_menu"; import link_context_menu from "../../../menus/link_context_menu"; import branches from "../../../services/branches"; -import froca from "../../../services/froca"; +import { getArchiveMenuItem } from "../../../menus/context_menu_utils"; import { t } from "../../../services/i18n"; -export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, parentNote: FNote) { +export function openCalendarContextMenu(e: ContextMenuEvent, note: FNote, parentNote: FNote) { e.preventDefault(); e.stopPropagation(); @@ -13,17 +14,15 @@ export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, par x: e.pageX, y: e.pageY, items: [ - ...link_context_menu.getItems(), + ...link_context_menu.getItems(e), { kind: "separator" }, + getArchiveMenuItem(note), { title: t("calendar_view.delete_note"), uiIcon: "bx bx-trash", handler: async () => { - const noteToDelete = await froca.getNote(noteId); - if (!noteToDelete) return; - let branchIdToDelete: string | null = null; - for (const parentBranch of noteToDelete.getParentBranches()) { + for (const parentBranch of note.getParentBranches()) { const parentNote = await parentBranch.getNote(); if (parentNote?.hasAncestor(parentNote.noteId)) { branchIdToDelete = parentBranch.branchId; @@ -34,8 +33,13 @@ export function openCalendarContextMenu(e: ContextMenuEvent, noteId: string, par await branches.deleteNotes([ branchIdToDelete ], false, false); } } + }, + { kind: "separator" }, + { + kind: "custom", + componentFn: () => NoteColorPicker({note: note}) } ], - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, noteId), + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, note.noteId), }) } diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index 8687dc6d90..b2665b7883 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -3,6 +3,7 @@ import froca from "../../../services/froca"; import { formatDateToLocalISO, getCustomisableLabel, getMonthsInDateRange, offsetDate } from "./utils"; import FNote from "../../../entities/fnote"; import server from "../../../services/server"; +import clsx from "clsx"; interface Event { startDate: string, @@ -80,7 +81,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) export async function buildEvent(note: FNote, { startDate, endDate, startTime, endTime, isArchived }: Event) { const customTitleAttributeName = note.getLabelValue("calendar:title"); const titles = await parseCustomTitle(customTitleAttributeName, note); - const color = note.getLabelValue("calendar:color") ?? note.getLabelValue("color"); + const colorClass = note.getColorClass(); const events: EventInput[] = []; const calendarDisplayedAttributes = note.getLabelValue("calendar:displayedAttributes")?.split(","); @@ -108,10 +109,9 @@ export async function buildEvent(note: FNote, { startDate, endDate, startTime, e start: startDate, url: `#${note.noteId}?popup`, noteId: note.noteId, - color: color ?? undefined, iconClass: note.getLabelValue("iconClass"), promotedAttributes: displayedAttributesData, - className: isArchived ? "archived" : "" + className: clsx({archived: isArchived}, colorClass) }; if (endDate) { eventData.end = endDate; diff --git a/apps/client/src/widgets/collections/calendar/index.css b/apps/client/src/widgets/collections/calendar/index.css index 5dd836fe61..9313b615ec 100644 --- a/apps/client/src/widgets/collections/calendar/index.css +++ b/apps/client/src/widgets/collections/calendar/index.css @@ -1,4 +1,21 @@ +:root { + /* Default values to be overridden by themes */ + --calendar-coll-event-background-lightness: 95%; + --calendar-coll-event-background-saturation: 80%; + --calendar-coll-event-background-color: var(--accented-background-color); + --calendar-coll-event-text-color: var(--main-text-color); + --calendar-coll-event-hover-filter: none; + --callendar-coll-event-archived-sripe-color: #00000013; + --calendar-coll-today-background-color: var(--more-accented-background-color); +} + .calendar-view { + --fc-event-border-color: var(--calendar-coll-event-text-color); + --fc-event-bg-color: var(--calendar-coll-event-background-color); + --fc-event-text-color: var(--calendar-coll-event-text-color); + --fc-event-selected-overlay-color: transparent; + --fc-today-bg-color: var(--calendar-coll-today-background-color); + overflow: hidden; position: relative; outline: 0; @@ -7,8 +24,9 @@ padding: 10px; } -.calendar-view a { - color: unset; +.calendar-view a, +:root .calendar-view a.fc-daygrid-event:hover { + color: var(--fc-event-text-color); } .search-result-widget-content .calendar-view { @@ -31,14 +49,6 @@ z-index: 50; } -.calendar-container a.fc-event { - text-decoration: none; -} - -.calendar-container a.fc-event.archived { - opacity: 0.5; -} - .calendar-container .fc-button { padding: 0.2em 0.5em; } @@ -75,4 +85,107 @@ body.desktop:not(.zen) .calendar-view .calendar-header { .search-result-widget-content .calendar-view .calendar-header { padding-inline-end: unset !important; } +/* #endregion */ + +/* #region Events */ + +/* + * week, month, year views + */ + + .calendar-container a.fc-event { + text-decoration: none; +} + +.calendar-container a.fc-event.archived { + opacity: .65; +} + +.calendar-container a.fc-event.archived::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -1; + + --c1: transparent; + --c2: var(--callendar-coll-event-archived-sripe-color); + + background: repeating-linear-gradient(45deg, var(--c1), var(--c1) 8px, + var(--c2) 8px, var(--c2) 16px); +} + +.calendar-view a.fc-timegrid-event, +.calendar-view a.fc-daygrid-event, +.calendar-view .fc-daygrid-dot-event .fc-event-title { + font-weight: 500; +} + +.calendar-view a.fc-timegrid-event, +.calendar-view a.fc-daygrid-event { + --border-color: transparent; + + border: 2px solid; + border-left-width: 4px; + border-color: var(--border-color) var(--border-color) var(--border-color) + var(--fc-event-text-color) !important; + background: var(--fc-event-bg-color) !important; + + padding-left: 8px; +} + +.calendar-view .fc-timegrid-event.with-hue, +.calendar-view .fc-daygrid-event.with-hue { + --fc-event-text-color: var(--custom-color); + + --fc-event-bg-color: hsl(var(--custom-color-hue), + var(--calendar-coll-event-background-saturation), + var(--calendar-coll-event-background-lightness)) !important; +} + +.calendar-view a.fc-timegrid-event:focus-visible, +.calendar-view a.fc-daygrid-event:focus-visible { + outline: none; +} + +.calendar-view a.fc-timegrid-event.fc-event-selected, +.calendar-view a.fc-timegrid-event.fc-event:focus, +.calendar-view a.fc-daygrid-event.fc-event-selected, +.calendar-view a.fc-daygrid-event.fc-event:focus { + --border-color: var(--custom-color, var(--input-focus-outline-color)); +} + +.calendar-view a.fc-timegrid-event:hover, +.calendar-view a.fc-daygrid-event:hover { + filter: var(--calendar-coll-event-hover-filter); + border-color: var(--fc-event-text-color); + text-decoration: none; + color: currentColor; + opacity: 1; +} + +.calendar-view .fc-daygrid-event-dot { + display: none; +} + + +.calendar-view .fc-event-time { + opacity: .75; +} + +/* + * List view + */ + +.fc-list-table tr.fc-event.archived { + opacity: .5; +} + +.fc-list-table .fc-list-event-dot { + /* Apply note colors to the list item dots */ + --fc-event-border-color: var(--custom-color); +} + /* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index bac6862b28..493640d25c 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -21,6 +21,7 @@ import ActionButton from "../../react/ActionButton"; import { RefObject } from "preact"; import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar"; import { openCalendarContextMenu } from "./context_menu"; +import { isMobile } from "../../../services/utils"; interface CalendarViewData { @@ -77,6 +78,7 @@ export const LOCALE_MAPPINGS: Record Promise<{ de "pt_br": () => import("@fullcalendar/core/locales/pt-br"), uk: () => import("@fullcalendar/core/locales/uk"), en: null, + "en-GB": () => import("@fullcalendar/core/locales/en-gb"), "en_rtl": null, ar: () => import("@fullcalendar/core/locales/ar") }; @@ -116,7 +118,10 @@ export default function CalendarView({ note, noteIds }: ViewModeProps noteIds.includes(noteId)) // note title change. || loadResults.getAttributeRows().some((a) => noteIds.includes(a.noteId ?? ""))) // subnote change. { - calendarRef.current?.refetchEvents(); + // Defer execution after the load results are processed so that the event builder has the updated data to work with. + setTimeout(() => { + calendarRef.current?.refetchEvents(); + }, 0); } }); @@ -262,7 +267,7 @@ function useEventDisplayCustomization(parentNote: FNote) { // Prepend the icon to the title, if any. if (iconClass) { - let titleContainer; + let titleContainer: HTMLElement | null = null; switch (e.view.type) { case "timeGridWeek": case "dayGridMonth": @@ -281,6 +286,9 @@ function useEventDisplayCustomization(parentNote: FNote) { } } + // Disable the default context menu. + e.el.dataset.noContextMenu = "true"; + // Append promoted attributes to the end of the event container. if (promotedAttributes) { let promotedAttributesHtml = ""; @@ -306,10 +314,18 @@ function useEventDisplayCustomization(parentNote: FNote) { $(mainContainer ?? e.el).append($(promotedAttributesHtml)); } - e.el.addEventListener("contextmenu", (contextMenuEvent) => { - const noteId = e.event.extendedProps.noteId; - openCalendarContextMenu(contextMenuEvent, noteId, parentNote); - }); + async function onContextMenu(contextMenuEvent: PointerEvent) { + const note = await froca.getNote(e.event.extendedProps.noteId); + if (!note) return; + + openCalendarContextMenu(contextMenuEvent, note, parentNote); + } + + if (isMobile()) { + e.el.addEventListener("click", onContextMenu); + } else { + e.el.addEventListener("contextmenu", onContextMenu); + } }, []); return { eventDidMount }; } diff --git a/apps/client/src/widgets/collections/geomap/context_menu.ts b/apps/client/src/widgets/collections/geomap/context_menu.ts index dd583e266a..47026566fc 100644 --- a/apps/client/src/widgets/collections/geomap/context_menu.ts +++ b/apps/client/src/widgets/collections/geomap/context_menu.ts @@ -2,6 +2,7 @@ import type { LatLng, LeafletMouseEvent } from "leaflet"; import appContext, { type CommandMappings } from "../../../components/app_context.js"; import contextMenu, { type MenuItem } from "../../../menus/context_menu.js"; import linkContextMenu from "../../../menus/link_context_menu.js"; +import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx"; import { t } from "../../../services/i18n.js"; import { createNewNote } from "./api.js"; import { copyTextWithToast } from "../../../services/clipboard_ext.js"; @@ -11,14 +12,19 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is let items: MenuItem[] = [ ...buildGeoLocationItem(e), { kind: "separator" }, - ...linkContextMenu.getItems(), + ...linkContextMenu.getItems(e), ]; if (isEditable) { items = [ ...items, { kind: "separator" }, - { title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" } + { title: t("geo-map-context.remove-from-map"), command: "deleteFromMap", uiIcon: "bx bx-trash" }, + { kind: "separator"}, + { + kind: "custom", + componentFn: () => NoteColorPicker({note: noteId}) + } ]; } @@ -26,14 +32,14 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is x: e.originalEvent.pageX, y: e.originalEvent.pageY, items, - selectMenuItemHandler: ({ command }, e) => { + selectMenuItemHandler: ({ command }) => { if (command === "deleteFromMap") { appContext.triggerCommand(command, { noteId }); return; } // Pass the events to the link context menu - linkContextMenu.handleLinkContextMenuItem(command, noteId); + linkContextMenu.handleLinkContextMenuItem(command, e, noteId); } }); } diff --git a/apps/client/src/widgets/collections/geomap/index.css b/apps/client/src/widgets/collections/geomap/index.css index 9fc5dee636..81039ba484 100644 --- a/apps/client/src/widgets/collections/geomap/index.css +++ b/apps/client/src/widgets/collections/geomap/index.css @@ -45,7 +45,7 @@ top: 3px; inset-inline-start: 2px; background-color: white; - color: black; + color: var(--light-theme-custom-color, black); padding: 2px; border-radius: 50%; font-size: 17px; @@ -79,4 +79,4 @@ body[dir=rtl] .geo-map-container .leaflet-div-icon .title-label { .geo-map-container.dark .leaflet-div-icon .title-label { color: white; text-shadow: -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black, 1px 1px 0 black; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/collections/geomap/index.tsx b/apps/client/src/widgets/collections/geomap/index.tsx index 07e942d199..6b91d4c8e6 100644 --- a/apps/client/src/widgets/collections/geomap/index.tsx +++ b/apps/client/src/widgets/collections/geomap/index.tsx @@ -130,7 +130,7 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM return (
- { coordinates && zoom && { if (!containerRef.current) return; const mapInstance = L.map(containerRef.current, { - worldCopyJump: true + worldCopyJump: false, + maxBounds: [ + [-90, -180], + [90, 180] + ], + minZoom: 2 }); mapRef.current = mapInstance; @@ -56,7 +61,8 @@ export default function Map({ coordinates, zoom, layerName, viewportChanged, chi } else { setLayer(L.tileLayer(layerData.url, { attribution: layerData.attribution, - detectRetina: true + detectRetina: true, + noWrap: true })); } } diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 7bec23a64d..1185992604 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -5,6 +5,8 @@ export type ViewTypeOptions = typeof allViewTypes[number]; export type ViewModeMedia = "screen" | "print"; +export type ProgressChangedFn = (progress: number) => void; + export interface ViewModeProps { note: FNote; notePath: string; @@ -17,4 +19,5 @@ export interface ViewModeProps { saveConfig(newConfig: T): void; media: ViewModeMedia; onReady(): void; + onProgressChanged?: ProgressChangedFn; } diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index ef37b6685e..850aa3fba1 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import Icon from "../../react/Icon"; import { ViewModeProps } from "../interface"; -import { useNoteLabelBoolean, useImperativeSearchHighlighlighting } from "../../react/hooks"; +import { useImperativeSearchHighlighlighting, useNoteLabel } from "../../react/hooks"; import NoteLink from "../../react/NoteLink"; import "./ListOrGridView.css"; import content_renderer from "../../../services/content_renderer"; @@ -11,9 +11,10 @@ import tree from "../../../services/tree"; import link from "../../../services/link"; import { t } from "../../../services/i18n"; import attribute_renderer from "../../../services/attribute_renderer"; +import { filterChildNotes, useFilteredNoteIds } from "./utils"; export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) { - const [ isExpanded ] = useNoteLabelBoolean(note, "expanded"); + const expandDepth = useExpansionDepth(note); const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); const { pageNotes, ...pagination } = usePagination(note, noteIds); @@ -24,7 +25,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens } @@ -55,12 +56,19 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens } ); } -function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: FNote, parentNote: FNote, expand?: boolean, highlightedTokens: string[] | null | undefined }) { - const [ isExpanded, setExpanded ] = useState(expand); +function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth }: { + note: FNote, + parentNote: FNote, + currentLevel: number, + expandDepth: number, + highlightedTokens: string[] | null | undefined +}) { + + const [ isExpanded, setExpanded ] = useState(currentLevel <= expandDepth); const notePath = getNotePath(parentNote, note); // Reset expand state if switching to another note, or if user manually toggled expansion state. - useEffect(() => setExpanded(expand), [ note, expand ]); + useEffect(() => setExpanded(currentLevel <= expandDepth), [ note, currentLevel, expandDepth ]); return (
- + }
) @@ -159,29 +167,25 @@ function NoteContent({ note, trim, noChildrenList, highlightedTokens }: { note: return
; } -function NoteChildren({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { - const imageLinks = note.getRelations("imageLink"); +function NoteChildren({ note, parentNote, highlightedTokens, currentLevel, expandDepth }: { + note: FNote, + parentNote: FNote, + currentLevel: number, + expandDepth: number, + highlightedTokens: string[] | null | undefined +}) { const [ childNotes, setChildNotes ] = useState(); useEffect(() => { - note.getChildNotes().then(childNotes => { - const filteredChildNotes = childNotes.filter((childNote) => !imageLinks.find((rel) => rel.value === childNote.noteId)); - setChildNotes(filteredChildNotes); - }); + filterChildNotes(note).then(setChildNotes); }, [ note ]); - return childNotes?.map(childNote => ) -} - -/** - * Filters the note IDs for the legacy view to filter out subnotes that are already included in the note content such as images, included notes. - */ -function useFilteredNoteIds(note: FNote, noteIds: string[]) { - return useMemo(() => { - const includedLinks = note ? note.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : []; - const includedNoteIds = new Set(includedLinks.map((rel) => rel.value)); - return noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); - }, noteIds); + return childNotes?.map(childNote => ) } function getNotePath(parentNote: FNote, childNote: FNote) { @@ -192,3 +196,17 @@ function getNotePath(parentNote: FNote, childNote: FNote) { return `${parentNote.noteId}/${childNote.noteId}` } } + +function useExpansionDepth(note: FNote) { + const [ expandDepth ] = useNoteLabel(note, "expanded"); + + if (expandDepth === null || expandDepth === undefined) { // not defined + return 0; + } else if (expandDepth === "") { // defined without value + return 1; + } else if (expandDepth === "all") { + return Number.MAX_SAFE_INTEGER; + } else { + return parseInt(expandDepth, 10); + } +} diff --git a/apps/client/src/widgets/collections/legacy/ListPrintView.tsx b/apps/client/src/widgets/collections/legacy/ListPrintView.tsx new file mode 100644 index 0000000000..07e140b175 --- /dev/null +++ b/apps/client/src/widgets/collections/legacy/ListPrintView.tsx @@ -0,0 +1,115 @@ +import { useEffect, useLayoutEffect, useState } from "preact/hooks"; +import froca from "../../../services/froca"; +import type FNote from "../../../entities/fnote"; +import content_renderer from "../../../services/content_renderer"; +import type { ViewModeProps } from "../interface"; +import { filterChildNotes, useFilteredNoteIds } from "./utils"; + +interface NotesWithContent { + note: FNote; + contentEl: HTMLElement; +} + +export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onProgressChanged }: ViewModeProps<{}>) { + const noteIds = useFilteredNoteIds(note, unfilteredNoteIds); + const [ notesWithContent, setNotesWithContent ] = useState(); + + useLayoutEffect(() => { + const noteIdsSet = new Set(); + + froca.getNotes(noteIds).then(async (notes) => { + const noteIdsWithChildren = await note.getSubtreeNoteIds(true); + const notesWithContent: NotesWithContent[] = []; + + async function processNote(note: FNote, depth: number) { + const content = await content_renderer.getRenderedContent(note, { + trim: false, + noChildrenList: true + }); + + const contentEl = content.$renderedContent[0]; + + insertPageTitle(contentEl, note.title); + rewriteHeadings(contentEl, depth); + noteIdsSet.add(note.noteId); + notesWithContent.push({ note, contentEl }); + + if (onProgressChanged) { + onProgressChanged(notesWithContent.length / noteIdsWithChildren.length); + } + + if (note.hasChildren()) { + const filteredChildNotes = await filterChildNotes(note); + for (const childNote of filteredChildNotes) { + await processNote(childNote, depth + 1); + } + } + } + + for (const note of notes) { + await processNote(note, 1); + } + + // After all notes are processed, rewrite links + for (const { contentEl } of notesWithContent) { + rewriteLinks(contentEl, noteIdsSet); + } + + setNotesWithContent(notesWithContent); + }); + }, [noteIds]); + + useEffect(() => { + if (notesWithContent && onReady) { + onReady(); + } + }, [ notesWithContent, onReady ]); + + return ( +
+ +
+ ); +} + +function insertPageTitle(contentEl: HTMLElement, title: string) { + const pageTitleEl = document.createElement("h1"); + pageTitleEl.textContent = title; + contentEl.prepend(pageTitleEl); +} + +function rewriteHeadings(contentEl: HTMLElement, depth: number) { + const headings = contentEl.querySelectorAll("h1, h2, h3, h4, h5, h6"); + for (const headingEl of headings) { + const currentLevel = parseInt(headingEl.tagName.substring(1), 10); + const newLevel = Math.min(currentLevel + depth, 6); + const newHeadingEl = document.createElement(`h${newLevel}`); + newHeadingEl.innerHTML = headingEl.innerHTML; + headingEl.replaceWith(newHeadingEl); + } +} + +function rewriteLinks(contentEl: HTMLElement, noteIdsSet: Set) { + const linkEls = contentEl.querySelectorAll("a"); + for (const linkEl of linkEls) { + const href = linkEl.getAttribute("href"); + if (href && href.startsWith("#root/")) { + const noteId = href.split("/").at(-1); + + if (noteId && noteIdsSet.has(noteId)) { + linkEl.setAttribute("href", `#note-${noteId}`); + } else { + // Link to note not in the print view, remove link but keep text + const spanEl = document.createElement("span"); + spanEl.innerHTML = linkEl.innerHTML; + linkEl.replaceWith(spanEl); + } + } + } +} diff --git a/apps/client/src/widgets/collections/legacy/utils.ts b/apps/client/src/widgets/collections/legacy/utils.ts new file mode 100644 index 0000000000..6432ce1d2f --- /dev/null +++ b/apps/client/src/widgets/collections/legacy/utils.ts @@ -0,0 +1,20 @@ +import { useMemo } from "preact/hooks"; +import FNote from "../../../entities/fnote"; + +/** + * Filters the note IDs for the legacy view to filter out subnotes that are already included in the note content such as images, included notes. + */ +export function useFilteredNoteIds(note: FNote, noteIds: string[]) { + return useMemo(() => { + const includedLinks = note ? note.getRelations().filter((rel) => rel.name === "imageLink" || rel.name === "includeNoteLink") : []; + const includedNoteIds = new Set(includedLinks.map((rel) => rel.value)); + return noteIds.filter((noteId) => !includedNoteIds.has(noteId) && noteId !== "_hidden"); + }, [ note, noteIds ]); +} + +export async function filterChildNotes(note: FNote) { + const imageLinks = note.getRelations("imageLink"); + const imageLinkNoteIds = new Set(imageLinks.map(rel => rel.value)); + const childNotes = await note.getChildNotes(); + return childNotes.filter((childNote) => !imageLinkNoteIds.has(childNote.noteId)); +} diff --git a/apps/client/src/widgets/collections/presentation/index.tsx b/apps/client/src/widgets/collections/presentation/index.tsx index ca236a46ce..0a3d36f65d 100644 --- a/apps/client/src/widgets/collections/presentation/index.tsx +++ b/apps/client/src/widgets/collections/presentation/index.tsx @@ -14,14 +14,14 @@ import { t } from "../../../services/i18n"; import { DEFAULT_THEME, loadPresentationTheme } from "./themes"; import FNote from "../../../entities/fnote"; -export default function PresentationView({ note, noteIds, media, onReady }: ViewModeProps<{}>) { +export default function PresentationView({ note, noteIds, media, onReady, onProgressChanged }: ViewModeProps<{}>) { const [ presentation, setPresentation ] = useState(); const containerRef = useRef(null); const [ api, setApi ] = useState(); const stylesheets = usePresentationStylesheets(note, media); function refresh() { - buildPresentationModel(note).then(setPresentation); + buildPresentationModel(note, onProgressChanged).then(setPresentation); } useTriliumEvent("entitiesReloaded", ({ loadResults }) => { diff --git a/apps/client/src/widgets/collections/presentation/model.ts b/apps/client/src/widgets/collections/presentation/model.ts index 92b7ffe763..f1c6cef779 100644 --- a/apps/client/src/widgets/collections/presentation/model.ts +++ b/apps/client/src/widgets/collections/presentation/model.ts @@ -1,6 +1,7 @@ import { NoteType } from "@triliumnext/commons"; import FNote from "../../../entities/fnote"; import contentRenderer from "../../../services/content_renderer"; +import { ProgressChangedFn } from "../interface"; type DangerouslySetInnerHTML = { __html: string; }; @@ -22,14 +23,26 @@ export interface PresentationModel { slides: PresentationSlideModel[]; } -export async function buildPresentationModel(note: FNote): Promise { +export async function buildPresentationModel(note: FNote, onProgressChanged?: ProgressChangedFn): Promise { const slideNotes = await note.getChildNotes(); - const slides: PresentationSlideModel[] = await Promise.all(slideNotes.map(async slideNote => ({ - ...(await buildSlideModel(slideNote)), - verticalSlides: note.type !== "search" ? await buildVerticalSlides(slideNote) : undefined - }))); + onProgressChanged?.(0.3); + const total = slideNotes.length; + let completed = 0; + const slidePromises = slideNotes.map(slideNote => (async () => { + const base = await buildSlideModel(slideNote); + const verticalSlides = note.type !== "search" ? await buildVerticalSlides(slideNote) : undefined; + const slide: PresentationSlideModel = { ...base, verticalSlides }; + completed++; + onProgressChanged?.(Math.min(0.3 + (completed / total) * 0.4, 0.7)); + return slide; + })()); + + const slides: PresentationSlideModel[] = await Promise.all(slidePromises); + + onProgressChanged?.(0.7); postProcessSlides(slides); + onProgressChanged?.(1); return { slides }; } diff --git a/apps/client/src/widgets/collections/table/TablePrintView.css b/apps/client/src/widgets/collections/table/TablePrintView.css new file mode 100644 index 0000000000..fef53c299f --- /dev/null +++ b/apps/client/src/widgets/collections/table/TablePrintView.css @@ -0,0 +1,20 @@ +.table-print-view .tabulator-print-table table, +.table-print-view .tabulator-print-table th, +.table-print-view .tabulator-print-table tr, +.table-print-view .tabulator-print-table td { + border: 1px solid black; + border-collapse: collapse; +} + +.table-print-view .tabulator-print-table th { + background-color: #f0f0f0; +} + +.table-print-view .tabulator-print-table th, +.table-print-view .tabulator-print-table td { + padding: 0.25rem 0.5rem; +} + +.table-print-view .tabulator-print-table td[aria-checked] svg path { + fill: currentColor; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/table/TablePrintView.tsx b/apps/client/src/widgets/collections/table/TablePrintView.tsx new file mode 100644 index 0000000000..534ba5764a --- /dev/null +++ b/apps/client/src/widgets/collections/table/TablePrintView.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { ViewModeProps } from "../interface"; +import useData, { TableConfig } from "./data"; +import { ExportModule, FormatModule, Tabulator as VanillaTabulator} from 'tabulator-tables'; +import Tabulator from "./tabulator"; +import { RawHtmlBlock } from "../../react/RawHtml"; +import "./TablePrintView.css"; + +export default function TablePrintView({ note, noteIds, viewConfig, onReady }: ViewModeProps) { + const tabulatorRef = useRef(null); + const { columnDefs, rowData, hasChildren } = useData(note, noteIds, viewConfig, undefined, () => {}); + const [ html, setHtml ] = useState(); + + useEffect(() => { + if (!html) return; + onReady?.(); + }, [ html ]); + + return rowData && ( + <> +

{note.title}

+ +
+ + {!html ? ( + { + const tabulator = tabulatorRef.current; + if (!tabulator) return; + setHtml(tabulator.getHtml()); + }} + /> + ) : ( + + )} +
+ + + ) +} diff --git a/apps/client/src/widgets/collections/table/context_menu.ts b/apps/client/src/widgets/collections/table/context_menu.ts index eb0a303ae6..2cb5ce4754 100644 --- a/apps/client/src/widgets/collections/table/context_menu.ts +++ b/apps/client/src/widgets/collections/table/context_menu.ts @@ -7,6 +7,7 @@ import link_context_menu from "../../../menus/link_context_menu.js"; import froca from "../../../services/froca.js"; import branches from "../../../services/branches.js"; import Component from "../../../components/component.js"; +import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx"; import { RefObject } from "preact"; export function useContextMenu(parentNote: FNote, parentComponent: Component | null | undefined, tabulator: RefObject): Partial { @@ -173,7 +174,7 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro contextMenu.show({ items: [ - ...link_context_menu.getItems(), + ...link_context_menu.getItems(e), { kind: "separator" }, { title: t("table_view.row-insert-above"), @@ -219,9 +220,14 @@ export function showRowContextMenu(parentComponent: Component, e: MouseEvent, ro title: t("table_context_menu.delete_row"), uiIcon: "bx bx-trash", handler: () => branches.deleteNotes([ rowData.branchId ], false, false) + }, + { kind: "separator"}, + { + kind: "custom", + componentFn: () => NoteColorPicker({note: rowData.noteId}) } ], - selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, rowData.noteId), + selectMenuItemHandler: ({ command }) => link_context_menu.handleLinkContextMenuItem(command, e, rowData.noteId), x: e.pageX, y: e.pageY }); diff --git a/apps/client/src/widgets/collections/table/data.tsx b/apps/client/src/widgets/collections/table/data.tsx new file mode 100644 index 0000000000..94909939db --- /dev/null +++ b/apps/client/src/widgets/collections/table/data.tsx @@ -0,0 +1,77 @@ +import type { ColumnDefinition } from "tabulator-tables"; +import FNote from "../../../entities/fnote"; +import { useNoteLabelBoolean, useNoteLabelInt, useTriliumEvent } from "../../react/hooks"; +import { useEffect, useState } from "preact/hooks"; +import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; +import froca from "../../../services/froca"; +import { buildColumnDefinitions } from "./columns"; +import attributes from "../../../services/attributes"; +import { RefObject } from "preact"; + +export interface TableConfig { + tableData: { + columns?: ColumnDefinition[]; + }; +} + +export default function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject | undefined, resetNewAttributePosition: () => void) { + const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth"); + const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); + + const [ columnDefs, setColumnDefs ] = useState(); + const [ rowData, setRowData ] = useState(); + const [ hasChildren, setHasChildren ] = useState(); + const [ isSorted ] = useNoteLabelBoolean(note, "sorted"); + const [ movableRows, setMovableRows ] = useState(false); + + async function refresh() { + const info = getAttributeDefinitionInformation(note); + + // Ensure all note IDs are loaded. + await froca.getNotes(noteIds); + + const { definitions: rowData, hasSubtree: hasChildren, rowNumber } = await buildRowDefinitions(note, info, includeArchived, maxDepth); + const columnDefs = buildColumnDefinitions({ + info, + movableRows, + existingColumnData: viewConfig?.tableData?.columns, + rowNumberHint: rowNumber, + position: newAttributePosition?.current ?? undefined + }); + setColumnDefs(columnDefs); + setRowData(rowData); + setHasChildren(hasChildren); + resetNewAttributePosition(); + } + + useEffect(() => { refresh() }, [ note, noteIds, maxDepth, movableRows ]); + + useTriliumEvent("entitiesReloaded", ({ loadResults}) => { + if (glob.device === "print") return; + + // React to column changes. + if (loadResults.getAttributeRows().find(attr => + attr.type === "label" && + (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && + attributes.isAffecting(attr, note))) { + refresh(); + return; + } + + // React to external row updates. + if (loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId || noteIds.includes(branch.parentNoteId ?? "")) + || loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) + || loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!)) + || loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) { + refresh(); + return; + } + }); + + // Identify if movable rows. + useEffect(() => { + setMovableRows(!isSorted && note.type !== "search" && !hasChildren); + }, [ isSorted, note, hasChildren ]); + + return { columnDefs, rowData, movableRows, hasChildren }; +} diff --git a/apps/client/src/widgets/collections/table/index.tsx b/apps/client/src/widgets/collections/table/index.tsx index f6ae820095..d557f12d33 100644 --- a/apps/client/src/widgets/collections/table/index.tsx +++ b/apps/client/src/widgets/collections/table/index.tsx @@ -1,10 +1,9 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ViewModeProps } from "../interface"; -import { buildColumnDefinitions } from "./columns"; -import getAttributeDefinitionInformation, { buildRowDefinitions, TableData } from "./rows"; -import { useLegacyWidget, useNoteLabelBoolean, useNoteLabelInt, useTriliumEvent } from "../../react/hooks"; +import { TableData } from "./rows"; +import { useLegacyWidget } from "../../react/hooks"; import Tabulator from "./tabulator"; -import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, ColumnDefinition, DataTreeModule, Options, RowComponent} from 'tabulator-tables'; +import { Tabulator as VanillaTabulator, SortModule, FormatModule, InteractionModule, EditModule, ResizeColumnsModule, FrozenColumnsModule, PersistenceModule, MoveColumnsModule, MoveRowsModule, DataTreeModule, Options, RowComponent} from 'tabulator-tables'; import { useContextMenu } from "./context_menu"; import { ParentComponent } from "../../react/react_utils"; import FNote from "../../../entities/fnote"; @@ -14,16 +13,8 @@ import "./index.css"; import useRowTableEditing from "./row_editing"; import useColTableEditing from "./col_editing"; import AttributeDetailWidget from "../../attribute_widgets/attribute_detail"; -import attributes from "../../../services/attributes"; -import { RefObject } from "preact"; import SpacedUpdate from "../../../services/spaced_update"; -import froca from "../../../services/froca"; - -interface TableConfig { - tableData: { - columns?: ColumnDefinition[]; - }; -} +import useData, { TableConfig } from "./data"; export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps) { const tabulatorRef = useRef(null); @@ -118,67 +109,7 @@ function usePersistence(viewConfig: TableConfig | null | undefined, saveConfig: return () => { spacedUpdate.updateNowIfNecessary(); }; - }, [ viewConfig, saveConfig ]) + }, [ viewConfig, saveConfig ]); return persistenceProps; } - -function useData(note: FNote, noteIds: string[], viewConfig: TableConfig | undefined, newAttributePosition: RefObject, resetNewAttributePosition: () => void) { - const [ maxDepth ] = useNoteLabelInt(note, "maxNestingDepth") ?? -1; - const [ includeArchived ] = useNoteLabelBoolean(note, "includeArchived"); - - const [ columnDefs, setColumnDefs ] = useState(); - const [ rowData, setRowData ] = useState(); - const [ hasChildren, setHasChildren ] = useState(); - const [ isSorted ] = useNoteLabelBoolean(note, "sorted"); - const [ movableRows, setMovableRows ] = useState(false); - - async function refresh() { - const info = getAttributeDefinitionInformation(note); - - // Ensure all note IDs are loaded. - await froca.getNotes(noteIds); - - const { definitions: rowData, hasSubtree: hasChildren, rowNumber } = await buildRowDefinitions(note, info, includeArchived, maxDepth); - const columnDefs = buildColumnDefinitions({ - info, - movableRows, - existingColumnData: viewConfig?.tableData?.columns, - rowNumberHint: rowNumber, - position: newAttributePosition.current ?? undefined - }); - setColumnDefs(columnDefs); - setRowData(rowData); - setHasChildren(hasChildren); - resetNewAttributePosition(); - } - - useEffect(() => { refresh() }, [ note, noteIds, maxDepth, movableRows ]); - - useTriliumEvent("entitiesReloaded", ({ loadResults}) => { - // React to column changes. - if (loadResults.getAttributeRows().find(attr => - attr.type === "label" && - (attr.name?.startsWith("label:") || attr.name?.startsWith("relation:")) && - attributes.isAffecting(attr, note))) { - refresh(); - return; - } - - // React to external row updates. - if (loadResults.getBranchRows().some(branch => branch.parentNoteId === note.noteId || noteIds.includes(branch.parentNoteId ?? "")) - || loadResults.getNoteIds().some(noteId => noteIds.includes(noteId)) - || loadResults.getAttributeRows().some(attr => noteIds.includes(attr.noteId!)) - || loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))) { - refresh(); - return; - } - }); - - // Identify if movable rows. - useEffect(() => { - setMovableRows(!isSorted && note.type !== "search" && !hasChildren); - }, [ isSorted, note, hasChildren ]); - - return { columnDefs, rowData, movableRows, hasChildren }; -} diff --git a/apps/client/src/widgets/collections/table/rows.spec.ts b/apps/client/src/widgets/collections/table/rows.spec.ts new file mode 100644 index 0000000000..a9269da23c --- /dev/null +++ b/apps/client/src/widgets/collections/table/rows.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildNote } from "../../../test/easy-froca"; +import getAttributeDefinitionInformation from "./rows.js"; + +describe("getAttributeDefinitionInformation", () => { + it("handles attributes with colons in their names", async () => { + const note = buildNote({ + title: "Note 1", + "#label:TEST:TEST1(inheritable)": "promoted,alias=Test1,single,text", + "#label:Test_Test2(inheritable)": "promoted,alias=Test2,single,text", + "#label:TEST:Test3(inheritable)": "promoted,alias=test3,single,text", + "#relation:TEST:TEST4(inheritable)": "promoted,alias=Test4,single", + "#relation:TEST:TEST5(inheritable)": "promoted,alias=Test5,single", + "#label:_TEST:TEST:TEST:Test1(inheritable)": "promoted,alias=Test01,single,text" + }); + const infos = getAttributeDefinitionInformation(note); + expect(infos).toMatchObject([ + { name: "TEST:TEST1", type: "text" }, + { name: "Test_Test2", type: "text" }, + { name: "TEST:Test3", type: "text" }, + { name: "TEST:TEST4", type: "relation" }, + { name: "TEST:TEST5", type: "relation" }, + { name: "_TEST:TEST:TEST:Test1", type: "text" } + ]); + }); +}); diff --git a/apps/client/src/widgets/collections/table/rows.ts b/apps/client/src/widgets/collections/table/rows.ts index e1b0a765e8..47530ecbde 100644 --- a/apps/client/src/widgets/collections/table/rows.ts +++ b/apps/client/src/widgets/collections/table/rows.ts @@ -1,5 +1,5 @@ import FNote from "../../../entities/fnote.js"; -import type { LabelType } from "../../../services/promoted_attribute_definition_parser.js"; +import { extractAttributeDefinitionTypeAndName, type LabelType } from "../../../services/promoted_attribute_definition_parser.js"; import type { AttributeDefinitionInformation } from "./columns.js"; export type TableData = { @@ -79,14 +79,14 @@ export default function getAttributeDefinitionInformation(parentNote: FNote) { continue; } - const [ labelType, name ] = attrDef.name.split(":", 2); + const [ attrType, name ] = extractAttributeDefinitionTypeAndName(attrDef.name); if (attrDef.type !== "label") { console.warn("Relations are not supported for now"); continue; } let type: LabelType | "relation" = def.labelType || "text"; - if (labelType === "relation") { + if (attrType === "relation") { type = "relation"; } diff --git a/apps/client/src/widgets/collections/table/tabulator.tsx b/apps/client/src/widgets/collections/table/tabulator.tsx index 6301d5b386..31fb8d4f85 100644 --- a/apps/client/src/widgets/collections/table/tabulator.tsx +++ b/apps/client/src/widgets/collections/table/tabulator.tsx @@ -14,9 +14,10 @@ interface TableProps extends Omit; index: keyof T; footerElement?: string | HTMLElement | JSX.Element; + onReady?: () => void; } -export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, dataTree, ...restProps }: TableProps) { +export default function Tabulator({ className, columns, data, modules, tabulatorRef: externalTabulatorRef, footerElement, events, index, dataTree, onReady, ...restProps }: TableProps) { const parentComponent = useContext(ParentComponent); const containerRef = useRef(null); const tabulatorRef = useRef(null); @@ -43,6 +44,7 @@ export default function Tabulator({ className, columns, data, modules, tabula tabulator.on("tableBuilt", () => { tabulatorRef.current = tabulator; externalTabulatorRef.current = tabulator; + onReady?.(); }); return () => tabulator.destroy(); diff --git a/apps/client/src/widgets/containers/launcher.ts b/apps/client/src/widgets/containers/launcher.ts deleted file mode 100644 index e1bfc5a8b9..0000000000 --- a/apps/client/src/widgets/containers/launcher.ts +++ /dev/null @@ -1,133 +0,0 @@ -import CalendarWidget from "../buttons/calendar.js"; -import SpacerWidget from "../spacer.js"; -import BookmarkButtons from "../bookmark_buttons.js"; -import ProtectedSessionStatusWidget from "../buttons/protected_session_status.js"; -import SyncStatusWidget from "../sync_status.js"; -import BasicWidget from "../basic_widget.js"; -import NoteLauncher from "../buttons/launcher/note_launcher.js"; -import ScriptLauncher from "../buttons/launcher/script_launcher.js"; -import CommandButtonWidget from "../buttons/command_button.js"; -import utils from "../../services/utils.js"; -import TodayLauncher from "../buttons/launcher/today_launcher.js"; -import HistoryNavigationButton from "../buttons/history_navigation.js"; -import QuickSearchLauncherWidget from "../quick_search_launcher.js"; -import type FNote from "../../entities/fnote.js"; -import type { CommandNames } from "../../components/app_context.js"; -import AiChatButton from "../buttons/ai_chat_button.js"; - -interface InnerWidget extends BasicWidget { - settings?: { - titlePlacement: "bottom"; - }; -} - -export default class LauncherWidget extends BasicWidget { - private innerWidget!: InnerWidget; - private isHorizontalLayout: boolean; - - constructor(isHorizontalLayout: boolean) { - super(); - - this.isHorizontalLayout = isHorizontalLayout; - } - - isEnabled() { - return this.innerWidget.isEnabled(); - } - - doRender() { - this.$widget = this.innerWidget.render(); - } - - async initLauncher(note: FNote) { - if (note.type !== "launcher") { - throw new Error(`Note '${note.noteId}' '${note.title}' is not a launcher even though it's in the launcher subtree`); - } - - if (!utils.isDesktop() && note.isLabelTruthy("desktopOnly")) { - return false; - } - - const launcherType = note.getLabelValue("launcherType"); - - if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") { - return false; - } - - let widget: BasicWidget; - if (launcherType === "command") { - widget = this.initCommandLauncherWidget(note).class("launcher-button"); - } else if (launcherType === "note") { - widget = new NoteLauncher(note).class("launcher-button"); - } else if (launcherType === "script") { - widget = new ScriptLauncher(note).class("launcher-button"); - } else if (launcherType === "customWidget") { - widget = await this.initCustomWidget(note); - } else if (launcherType === "builtinWidget") { - widget = this.initBuiltinWidget(note); - } else { - throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`); - } - - if (!widget) { - throw new Error(`Unknown initialization error for note '${note.noteId}', title '${note.title}'`); - } - - this.child(widget); - this.innerWidget = widget as InnerWidget; - if (this.isHorizontalLayout && this.innerWidget.settings) { - this.innerWidget.settings.titlePlacement = "bottom"; - } - - return true; - } - - initCommandLauncherWidget(note: FNote) { - return new CommandButtonWidget() - .title(() => note.title) - .icon(() => note.getIcon()) - .command(() => note.getLabelValue("command") as CommandNames); - } - - async initCustomWidget(note: FNote) { - const widget = await note.getRelationTarget("widget"); - - if (widget) { - return await widget.executeScript(); - } else { - throw new Error(`Custom widget of launcher '${note.noteId}' '${note.title}' is not defined.`); - } - } - - initBuiltinWidget(note: FNote) { - const builtinWidget = note.getLabelValue("builtinWidget"); - switch (builtinWidget) { - case "calendar": - return new CalendarWidget(note.title, note.getIcon()); - case "spacer": - // || has to be inside since 0 is a valid value - const baseSize = parseInt(note.getLabelValue("baseSize") || "40"); - const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100"); - - return new SpacerWidget(baseSize, growthFactor); - case "bookmarks": - return new BookmarkButtons(this.isHorizontalLayout); - case "protectedSession": - return new ProtectedSessionStatusWidget(); - case "syncStatus": - return new SyncStatusWidget(); - case "backInHistoryButton": - return new HistoryNavigationButton(note, "backInNoteHistory"); - case "forwardInHistoryButton": - return new HistoryNavigationButton(note, "forwardInNoteHistory"); - case "todayInJournal": - return new TodayLauncher(note); - case "quickSearch": - return new QuickSearchLauncherWidget(this.isHorizontalLayout); - case "aiChatLauncher": - return new AiChatButton(note); - default: - throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); - } - } -} diff --git a/apps/client/src/widgets/containers/launcher_container.ts b/apps/client/src/widgets/containers/launcher_container.ts deleted file mode 100644 index f684d4e6bd..0000000000 --- a/apps/client/src/widgets/containers/launcher_container.ts +++ /dev/null @@ -1,78 +0,0 @@ -import FlexContainer from "./flex_container.js"; -import froca from "../../services/froca.js"; -import appContext, { type EventData } from "../../components/app_context.js"; -import LauncherWidget from "./launcher.js"; -import utils from "../../services/utils.js"; - -export default class LauncherContainer extends FlexContainer { - private isHorizontalLayout: boolean; - - constructor(isHorizontalLayout: boolean) { - super(isHorizontalLayout ? "row" : "column"); - - this.id("launcher-container"); - this.filling(); - this.isHorizontalLayout = isHorizontalLayout; - - this.load(); - } - - async load() { - await froca.initializedPromise; - - const visibleLaunchersRootId = utils.isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers"; - const visibleLaunchersRoot = await froca.getNote(visibleLaunchersRootId, true); - - if (!visibleLaunchersRoot) { - console.log("Visible launchers root note doesn't exist."); - - return; - } - - const newChildren: LauncherWidget[] = []; - - for (const launcherNote of await visibleLaunchersRoot.getChildNotes()) { - try { - const launcherWidget = new LauncherWidget(this.isHorizontalLayout); - const success = await launcherWidget.initLauncher(launcherNote); - - if (success) { - newChildren.push(launcherWidget); - } - } catch (e) { - console.error(e); - } - } - - this.children = []; - this.child(...newChildren); - - this.$widget.empty(); - this.renderChildren(); - - await this.handleEventInChildren("initialRenderComplete", {}); - - const activeContext = appContext.tabManager.getActiveContext(); - - if (activeContext) { - await this.handleEvent("setNoteContext", { - noteContext: activeContext - }); - - if (activeContext.notePath) { - await this.handleEvent("noteSwitched", { - noteContext: activeContext, - notePath: activeContext.notePath - }); - } - } - } - - entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) { - // changes in note placement require reload of all launchers, all other changes are handled by individual - // launchers - this.load(); - } - } -} diff --git a/apps/client/src/widgets/containers/left_pane_container.ts b/apps/client/src/widgets/containers/left_pane_container.ts index e6bc78e232..cd832560bc 100644 --- a/apps/client/src/widgets/containers/left_pane_container.ts +++ b/apps/client/src/widgets/containers/left_pane_container.ts @@ -29,7 +29,11 @@ export default class LeftPaneContainer extends FlexContainer { if (visible) { this.triggerEvent("focusTree", {}); } else { - this.triggerEvent("focusOnDetail", { ntxId: appContext.tabManager.getActiveContext()?.ntxId }); + const ntxId = appContext.tabManager.getActiveContext()?.ntxId; + const noteContainer = document.querySelector(`.note-split[data-ntx-id="${ntxId}"]`); + if (!noteContainer?.contains(document.activeElement)) { + this.triggerEvent("focusOnDetail", { ntxId }); + } } options.save("leftPaneVisible", this.currentLeftPaneVisible.toString()); diff --git a/apps/client/src/widgets/containers/right_pane_container.ts b/apps/client/src/widgets/containers/right_pane_container.ts index 84875bec18..1c88f36959 100644 --- a/apps/client/src/widgets/containers/right_pane_container.ts +++ b/apps/client/src/widgets/containers/right_pane_container.ts @@ -5,6 +5,7 @@ import type { EventData, EventNames } from "../../components/app_context.js"; export default class RightPaneContainer extends FlexContainer { private rightPaneHidden: boolean; + private firstRender: boolean; constructor() { super("column"); @@ -14,6 +15,7 @@ export default class RightPaneContainer extends FlexContainer this.collapsible(); this.rightPaneHidden = false; + this.firstRender = true; } isEnabled() { @@ -41,10 +43,11 @@ export default class RightPaneContainer extends FlexContainer const oldToggle = !this.isHiddenInt(); const newToggle = this.isEnabled(); - if (oldToggle !== newToggle) { + if (oldToggle !== newToggle || this.firstRender) { this.toggleInt(newToggle); splitService.setupRightPaneResizer(); + this.firstRender = false; } } diff --git a/apps/client/src/widgets/containers/split_note_container.ts b/apps/client/src/widgets/containers/split_note_container.ts index 02ed8cf045..1cee46b73b 100644 --- a/apps/client/src/widgets/containers/split_note_container.ts +++ b/apps/client/src/widgets/containers/split_note_container.ts @@ -1,12 +1,10 @@ import FlexContainer from "./flex_container.js"; import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js"; import type BasicWidget from "../basic_widget.js"; -import type NoteContext from "../../components/note_context.js"; import Component from "../../components/component.js"; import splitService from "../../services/resizer.js"; -interface NoteContextEvent { - noteContext: NoteContext; -} +import { isMobile } from "../../services/utils.js"; +import NoteContext from "../../components/note_context.js"; interface SplitNoteWidget extends BasicWidget { hasBeenAlreadyShown?: boolean; @@ -53,13 +51,14 @@ export default class SplitNoteContainer extends FlexContainer { this.child(widget); - if (noteContext.mainNtxId && noteContext.ntxId) { + if (noteContext.mainNtxId && noteContext.ntxId && !isMobile()) { splitService.setupNoteSplitResizer([noteContext.mainNtxId,noteContext.ntxId]); } } async openNewNoteSplitEvent({ ntxId, notePath, hoistedNoteId, viewScope }: EventData<"openNewNoteSplit">) { - const mainNtxId = appContext.tabManager.getActiveMainContext()?.ntxId; + const activeContext = appContext.tabManager.getActiveMainContext(); + const mainNtxId = activeContext?.ntxId; if (!mainNtxId) { console.warn("Missing main note context ID"); return; @@ -73,22 +72,30 @@ export default class SplitNoteContainer extends FlexContainer { hoistedNoteId = hoistedNoteId || appContext.tabManager.getActiveContext()?.hoistedNoteId; - const noteContext = await appContext.tabManager.openEmptyTab(null, hoistedNoteId, mainNtxId); - if (!noteContext.ntxId) { - logError("Failed to create new note context!"); - return; + + const subContexts = activeContext.getSubContexts(); + let noteContext: NoteContext | undefined = undefined; + if (isMobile() && subContexts.length > 1) { + noteContext = subContexts.find(s => s.ntxId !== ntxId); + } + if (!noteContext) { + noteContext = await appContext.tabManager.openEmptyTab(null, hoistedNoteId, mainNtxId); + // remove the original position of newly created note context + const ntxIds = appContext.tabManager.children.map((c) => c.ntxId).filter((id) => id !== noteContext?.ntxId) as string[]; + + // insert the note context after the originating note context + if (!noteContext.ntxId) { + logError("Failed to create new note context!"); + return; + } + ntxIds.splice(ntxIds.indexOf(ntxId) + 1, 0, noteContext.ntxId); + + this.triggerCommand("noteContextReorder", { ntxIdsInOrder: ntxIds }); + + // move the note context rendered widget after the originating widget + this.$widget.find(`[data-ntx-id="${noteContext.ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${ntxId}"]`)); } - // remove the original position of newly created note context - const ntxIds = appContext.tabManager.children.map((c) => c.ntxId).filter((id) => id !== noteContext.ntxId) as string[]; - - // insert the note context after the originating note context - ntxIds.splice(ntxIds.indexOf(ntxId) + 1, 0, noteContext.ntxId); - - this.triggerCommand("noteContextReorder", { ntxIdsInOrder: ntxIds }); - - // move the note context rendered widget after the originating widget - this.$widget.find(`[data-ntx-id="${noteContext.ntxId}"]`).insertAfter(this.$widget.find(`[data-ntx-id="${ntxId}"]`)); await appContext.tabManager.activateNoteContext(noteContext.ntxId); diff --git a/apps/client/src/widgets/dialogs/PopupEditor.css b/apps/client/src/widgets/dialogs/PopupEditor.css new file mode 100644 index 0000000000..41a1d87368 --- /dev/null +++ b/apps/client/src/widgets/dialogs/PopupEditor.css @@ -0,0 +1,108 @@ +/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */ +body.popup-editor-open > .modal-backdrop { z-index: 998; } +body.popup-editor-open .popup-editor-dialog { z-index: 999; } +body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; } + +body.desktop .modal.popup-editor-dialog .modal-dialog { + max-width: 75vw; + border-bottom-left-radius: var(--bs-modal-border-radius); + border-bottom-right-radius: var(--bs-modal-border-radius); +} + +body.desktop .modal.popup-editor-dialog .modal-dialog { + overflow: hidden; +} + +body.mobile .modal.popup-editor-dialog .modal-dialog { + max-width: min(var(--preferred-max-content-width), 95vw); + max-height: var(--tn-modal-max-height); + height: 100%; +} + +.modal.popup-editor-dialog .modal-content { + transition: background-color 250ms ease-in; +} + +.modal.popup-editor-dialog .modal-header .modal-title { + font-size: 1.1em; +} + +.modal.popup-editor-dialog .modal-header .title-row { + flex-grow: 1; + display: flex; + align-items: center; +} + +.modal.popup-editor-dialog .modal-header .note-title-widget { + margin-top: 8px; +} + +.modal.popup-editor-dialog .modal-body { + padding: 0; + height: 75vh; + overflow: auto; + display: flex; + flex-direction: column; +} + +.modal.popup-editor-dialog .title-row, +.modal.popup-editor-dialog .modal-title, +.modal.popup-editor-dialog .note-icon-widget { + height: 32px; +} + +.modal.popup-editor-dialog .note-icon-widget { + width: 32px; + margin: 0; + padding: 0; +} + +.modal.popup-editor-dialog .note-icon-widget button.note-icon, +.modal.popup-editor-dialog .note-title-widget input.note-title { + font-size: 1em; +} + +.modal.popup-editor-dialog .classic-toolbar-outer-container.visible { + background-color: transparent; +} + +.modal.popup-editor-dialog div.promoted-attributes-container { + margin-block: 0; +} + +.modal.popup-editor-dialog .classic-toolbar-widget { + position: sticky; + margin-inline: 8px; + top: 0; + inset-inline-start: 0; + inset-inline-end: 0; + background: var(--modal-background-color); + z-index: 998; + align-items: flex-start; +} + +.modal.popup-editor-dialog .note-detail.full-height { + flex-grow: 0; + height: 100%; +} + +.modal.popup-editor-dialog .note-detail-editable-text { + padding: 0 1em; +} + +.modal.popup-editor-dialog .note-detail-file { + padding: 0; +} + +.modal.popup-editor-dialog .note-detail-readonly-text { + padding: 1em; +} + +.modal.popup-editor-dialog .note-detail-code-editor { + padding: 0; + + & .cm-editor { + margin: 0; + border-radius: 0; + } +} diff --git a/apps/client/src/widgets/dialogs/PopupEditor.tsx b/apps/client/src/widgets/dialogs/PopupEditor.tsx new file mode 100644 index 0000000000..c85dcd3b37 --- /dev/null +++ b/apps/client/src/widgets/dialogs/PopupEditor.tsx @@ -0,0 +1,120 @@ +import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import Modal from "../react/Modal"; +import "./PopupEditor.css"; +import { useNoteContext, useNoteLabel, useTriliumEvent } from "../react/hooks"; +import NoteTitleWidget from "../note_title"; +import NoteIcon from "../note_icon"; +import NoteContext from "../../components/note_context"; +import { NoteContextContext, ParentComponent } from "../react/react_utils"; +import NoteDetail from "../NoteDetail"; +import { ComponentChildren } from "preact"; +import NoteList from "../collections/NoteList"; +import StandaloneRibbonAdapter from "../ribbon/components/StandaloneRibbonAdapter"; +import FormattingToolbar from "../ribbon/FormattingToolbar"; +import PromotedAttributes from "../PromotedAttributes"; +import FloatingButtons from "../FloatingButtons"; +import { DESKTOP_FLOATING_BUTTONS, MOBILE_FLOATING_BUTTONS, POPUP_HIDDEN_FLOATING_BUTTONS } from "../FloatingButtonsDefinitions"; +import utils from "../../services/utils"; +import tree from "../../services/tree"; +import froca from "../../services/froca"; +import ReadOnlyNoteInfoBar from "../ReadOnlyNoteInfoBar"; +import MobileEditorToolbar from "../type_widgets/text/mobile_editor_toolbar"; +import { t } from "../../services/i18n"; +import appContext from "../../components/app_context"; + +export default function PopupEditor() { + const [ shown, setShown ] = useState(false); + const parentComponent = useContext(ParentComponent); + const [ noteContext, setNoteContext ] = useState(new NoteContext("_popup-editor")); + const isMobile = utils.isMobile(); + const items = useMemo(() => { + const baseItems = isMobile ? MOBILE_FLOATING_BUTTONS : DESKTOP_FLOATING_BUTTONS; + return baseItems.filter(item => !POPUP_HIDDEN_FLOATING_BUTTONS.includes(item)); + }, [ isMobile ]); + + useTriliumEvent("openInPopup", async ({ noteIdOrPath }) => { + const noteContext = new NoteContext("_popup-editor"); + + const noteId = tree.getNoteIdAndParentIdFromUrl(noteIdOrPath); + if (!noteId.noteId) return; + const note = await froca.getNote(noteId.noteId); + if (!note) return; + + const hasUserSetNoteReadOnly = note.hasLabel("readOnly"); + await noteContext.setNote(noteIdOrPath, { + viewScope: { + // Override auto-readonly notes to be editable, but respect user's choice to have a read-only note. + readOnlyTemporarilyDisabled: !hasUserSetNoteReadOnly + } + }); + + setNoteContext(noteContext); + setShown(true); + }); + + // Add a global class to be able to handle issues with z-index due to rendering in a popup. + useEffect(() => { + document.body.classList.toggle("popup-editor-open", shown); + }, [shown]); + + return ( + + + } + customTitleBarButtons={[{ + iconClassName: "bx-expand-alt", + title: t("popup-editor.maximize"), + onClick: async () => { + if (!noteContext.noteId) return; + const { noteId, hoistedNoteId } = noteContext; + await appContext.tabManager.openInNewTab(noteId, hoistedNoteId, true); + setShown(false); + } + }]} + className="popup-editor-dialog" + size="lg" + show={shown} + onShown={() => { + parentComponent?.handleEvent("focusOnDetail", { ntxId: noteContext.ntxId }); + }} + onHidden={() => setShown(false)} + keepInDom // needed for faster loading + noFocus // automatic focus breaks block popup + > + + + + {isMobile + ? + : } + + + + + + + + ) +} + +export function DialogWrapper({ children }: { children: ComponentChildren }) { + const { note } = useNoteContext(); + const wrapperRef = useRef(null); + useNoteLabel(note, "color"); // to update color class + + return ( +
+ {children} +
+ ) +} + +export function TitleRow() { + return ( +
+ + +
+ ) +} diff --git a/apps/client/src/widgets/dialogs/about.tsx b/apps/client/src/widgets/dialogs/about.tsx index 7fa9c2390d..f09cca3191 100644 --- a/apps/client/src/widgets/dialogs/about.tsx +++ b/apps/client/src/widgets/dialogs/about.tsx @@ -31,29 +31,29 @@ export default function AboutDialog() { {t("about.homepage")} -
https://github.com/TriliumNext/Trilium + https://github.com/TriliumNext/Trilium {t("about.app_version")} - {appInfo?.appVersion} + {appInfo?.appVersion} {t("about.db_version")} - {appInfo?.dbVersion} + {appInfo?.dbVersion} {t("about.sync_version")} - {appInfo?.syncVersion} + {appInfo?.syncVersion} {t("about.build_date")} - + {appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""} {t("about.build_revision")} - + {appInfo?.buildRevision && {appInfo.buildRevision}} @@ -76,8 +76,8 @@ function DirectoryLink({ directory, style }: { directory: string, style?: CSSPro openService.openDirectory(directory); }; - return {directory} + return {directory} } else { - return {directory}; + return {directory}; } } diff --git a/apps/client/src/widgets/dialogs/branch_prefix.tsx b/apps/client/src/widgets/dialogs/branch_prefix.tsx index e715c894f6..e46b3d36ad 100644 --- a/apps/client/src/widgets/dialogs/branch_prefix.tsx +++ b/apps/client/src/widgets/dialogs/branch_prefix.tsx @@ -103,7 +103,7 @@ export default function BranchPrefixDialog() { setPrefix((e.target as HTMLInputElement).value)} /> {isSingleBranch && branches[0] && ( -
- {branches[0].getNoteFromCache().title}
+
- {branches[0].getNoteFromCache()?.title}
)}
@@ -113,7 +113,7 @@ export default function BranchPrefixDialog() {
    {branches.map((branch) => { const note = branch.getNoteFromCache(); - return ( + return note && (
  • {branch.prefix && {branch.prefix} - } {note.title} diff --git a/apps/client/src/widgets/dialogs/confirm.tsx b/apps/client/src/widgets/dialogs/confirm.tsx index 456f11ad27..79829d4d4d 100644 --- a/apps/client/src/widgets/dialogs/confirm.tsx +++ b/apps/client/src/widgets/dialogs/confirm.tsx @@ -4,12 +4,14 @@ import { t } from "../../services/i18n"; import { useState } from "preact/hooks"; import FormCheckbox from "../react/FormCheckbox"; import { useTriliumEvent } from "../react/hooks"; +import { isValidElement, type VNode } from "preact"; +import { RawHtmlBlock } from "../react/RawHtml"; interface ConfirmDialogProps { title?: string; - message?: string | HTMLElement; + message?: MessageType; callback?: ConfirmDialogCallback; - isConfirmDeleteNoteBox?: boolean; + isConfirmDeleteNoteBox?: boolean; } export default function ConfirmDialog() { @@ -20,7 +22,7 @@ export default function ConfirmDialog() { function showDialog(title: string | null, message: MessageType, callback: ConfirmDialogCallback, isConfirmDeleteNoteBox: boolean) { setOpts({ title: title ?? undefined, - message: (typeof message === "object" && "length" in message ? message[0] : message), + message, callback, isConfirmDeleteNoteBox }); @@ -30,7 +32,7 @@ export default function ConfirmDialog() { useTriliumEvent("showConfirmDialog", ({ message, callback }) => showDialog(null, message, callback, false)); useTriliumEvent("showConfirmDeleteNoteBoxWithNoteDialog", ({ title, callback }) => showDialog(title, t("confirm.are_you_sure_remove_note", { title: title }), callback, true)); - return ( + return ( - {!opts?.message || typeof opts?.message === "string" - ?
    {(opts?.message as string) ?? ""}
    - :
    } + {isValidElement(opts?.message) + ? opts?.message + : + } {opts?.isConfirmDeleteNoteBox && ( void; -type MessageType = string | HTMLElement | JQuery; +export type MessageType = string | HTMLElement | JQuery | VNode; export interface ConfirmDialogOptions { confirmed: boolean; diff --git a/apps/client/src/widgets/dialogs/export.tsx b/apps/client/src/widgets/dialogs/export.tsx index b694d9abe5..2f10f8063b 100644 --- a/apps/client/src/widgets/dialogs/export.tsx +++ b/apps/client/src/widgets/dialogs/export.tsx @@ -6,7 +6,7 @@ import FormRadioGroup from "../react/FormRadioGroup"; import Modal from "../react/Modal"; import "./export.css"; import ws from "../../services/ws"; -import toastService, { ToastOptions } from "../../services/toast"; +import toastService, { type ToastOptionsWithRequiredId } from "../../services/toast"; import utils from "../../services/utils"; import open from "../../services/open"; import froca from "../../services/froca"; @@ -132,11 +132,11 @@ function exportBranch(branchId: string, type: string, format: string, version: s } ws.subscribeToMessages(async (message) => { - function makeToast(id: string, message: string): ToastOptions { + function makeToast(id: string, message: string): ToastOptionsWithRequiredId { return { - id: id, + id, title: t("export.export_status"), - message: message, + message, icon: "export" }; } @@ -152,7 +152,7 @@ ws.subscribeToMessages(async (message) => { toastService.showPersistent(makeToast(message.taskId, t("export.export_in_progress", { progressCount: message.progressCount }))); } else if (message.type === "taskSucceeded") { const toast = makeToast(message.taskId, t("export.export_finished_successfully")); - toast.closeAfter = 5000; + toast.timeout = 5000; toastService.showPersistent(toast); } diff --git a/apps/client/src/widgets/dialogs/help.tsx b/apps/client/src/widgets/dialogs/help.tsx index d5c2f695d5..f6c0c96d60 100644 --- a/apps/client/src/widgets/dialogs/help.tsx +++ b/apps/client/src/widgets/dialogs/help.tsx @@ -1,7 +1,7 @@ import Modal from "../react/Modal.jsx"; import { t } from "../../services/i18n.js"; import { ComponentChildren } from "preact"; -import { CommandNames } from "../../components/app_context.js"; +import appContext, { CommandNames } from "../../components/app_context.js"; import RawHtml from "../react/RawHtml.jsx"; import { useEffect, useState } from "preact/hooks"; import keyboard_actions from "../../services/keyboard_actions.js"; @@ -14,6 +14,7 @@ export default function HelpDialog() { return ( setShown(false)} show={shown} > @@ -160,3 +161,7 @@ function Card({ title, children }: { title: string, children: ComponentChildren
    ) } + +function editShortcuts() { + appContext.tabManager.openContextWithNote("_optionsShortcuts", { activate: true }); +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/info.css b/apps/client/src/widgets/dialogs/info.css new file mode 100644 index 0000000000..e1ee89affd --- /dev/null +++ b/apps/client/src/widgets/dialogs/info.css @@ -0,0 +1,11 @@ +.modal.info-dialog { + user-select: text; + + h3 { + font-size: 1.25em; + } + + pre { + font-size: 0.75em; + } +} diff --git a/apps/client/src/widgets/dialogs/info.tsx b/apps/client/src/widgets/dialogs/info.tsx index 334f43ce6c..e53faa8bdb 100644 --- a/apps/client/src/widgets/dialogs/info.tsx +++ b/apps/client/src/widgets/dialogs/info.tsx @@ -1,12 +1,26 @@ import { EventData } from "../../components/app_context"; -import Modal from "../react/Modal"; +import Modal, { type ModalProps } from "../react/Modal"; import { t } from "../../services/i18n"; import Button from "../react/Button"; import { useRef, useState } from "preact/hooks"; import { RawHtmlBlock } from "../react/RawHtml"; import { useTriliumEvent } from "../react/hooks"; +import { isValidElement } from "preact"; +import { ConfirmWithMessageOptions } from "./confirm"; +import "./info.css"; +import server from "../../services/server"; +import { ToMarkdownResponse } from "@triliumnext/commons"; +import { copyTextWithToast } from "../../services/clipboard_ext"; + +export interface InfoExtraProps extends Partial> { + /** Adds a button in the footer that allows easily copying the content of the infobox to clipboard. */ + copyToClipboardButton?: boolean; +} + +export type InfoProps = ConfirmWithMessageOptions & InfoExtraProps; export default function InfoDialog() { + const modalRef = useRef(null); const [ opts, setOpts ] = useState>(); const [ shown, setShown ] = useState(false); const okButtonRef = useRef(null); @@ -18,21 +32,42 @@ export default function InfoDialog() { return ( { opts?.callback?.(); setShown(false); }} onShown={() => okButtonRef.current?.focus?.()} - footer={ -
- - -
-
- - -`; - -export default class PopupEditorDialog extends Container { - - private noteContext: NoteContext; - private $modalHeader!: JQuery; - private $modalBody!: JQuery; - private $wrapper!: JQuery; - - constructor() { - super(); - this.noteContext = new NoteContext("_popup-editor"); - } - - doRender() { - // This will populate this.$widget with the content of the children. - super.doRender(); - - // Now we wrap it in the modal. - const $newWidget = $(TPL); - this.$modalHeader = $newWidget.find(".modal-title"); - this.$modalBody = $newWidget.find(".modal-body"); - this.$wrapper = $newWidget.find(".quick-edit-dialog-wrapper"); - - const children = this.$widget.children(); - this.$modalHeader.append(children[0]); - this.$modalBody.append(children.slice(1)); - this.$widget = $newWidget; - this.setVisibility(false); - } - - async openInPopupEvent({ noteIdOrPath }: EventData<"openInPopup">) { - const $dialog = await openDialog(this.$widget, false, { - focus: false - }); - - await this.noteContext.setNote(noteIdOrPath, { - viewScope: { - readOnlyTemporarilyDisabled: true - } - }); - - const colorClass = this.noteContext.note?.getColorClass(); - const wrapperElement = this.$wrapper.get(0)!; - - if (colorClass) { - wrapperElement.className = "quick-edit-dialog-wrapper " + colorClass; - } else { - wrapperElement.className = "quick-edit-dialog-wrapper"; - } - - const customHue = getComputedStyle(wrapperElement).getPropertyValue("--custom-color-hue"); - if (customHue) { - /* Apply the tinted-dialog class only if the custom color CSS class specifies a hue */ - wrapperElement.classList.add("tinted-quick-edit-dialog"); - } - - const activeEl = document.activeElement; - if (activeEl && "blur" in activeEl) { - (activeEl as HTMLElement).blur(); - } - - $dialog.on("shown.bs.modal", async () => { - await this.handleEventInChildren("activeContextChanged", { noteContext: this.noteContext }); - this.setVisibility(true); - await this.handleEventInChildren("focusOnDetail", { ntxId: this.noteContext.ntxId }); - }); - $dialog.on("hidden.bs.modal", () => { - const $typeWidgetEl = $dialog.find(".note-detail-printable"); - if ($typeWidgetEl.length) { - const typeWidget = glob.getComponentByEl($typeWidgetEl[0]) as ReactWrappedWidget; - typeWidget.cleanup(); - } - - this.setVisibility(false); - }); - } - - setVisibility(visible: boolean) { - const $bodyItems = this.$modalBody.find("> div"); - if (visible) { - $bodyItems.fadeIn(); - this.$modalHeader.children().show(); - document.body.classList.add("popup-editor-open"); - - } else { - $bodyItems.hide(); - this.$modalHeader.children().hide(); - document.body.classList.remove("popup-editor-open"); - } - } - - handleEventInChildren(name: T, data: EventData): Promise | null { - // Avoid events related to the current tab interfere with our popup. - if (["noteSwitched", "noteSwitchedAndActivated", "exportAsPdf", "printActiveNote"].includes(name)) { - return Promise.resolve(); - } - - // Avoid not showing recent notes when creating a new empty tab. - if ("noteContext" in data && data.noteContext.ntxId !== "_popup-editor") { - return Promise.resolve(); - } - - return super.handleEventInChildren(name, data); - } - -} diff --git a/apps/client/src/widgets/dialogs/recent_changes.tsx b/apps/client/src/widgets/dialogs/recent_changes.tsx index 295650e545..60de8b1b0a 100644 --- a/apps/client/src/widgets/dialogs/recent_changes.tsx +++ b/apps/client/src/widgets/dialogs/recent_changes.tsx @@ -21,7 +21,7 @@ export default function RecentChangesDialog() { const [ refreshCounter, setRefreshCounter ] = useState(0); const [ shown, setShown ] = useState(false); - useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => { + useTriliumEvent("showRecentChanges", ({ ancestorNoteId }) => { setAncestorNoteId(ancestorNoteId ?? hoisted_note.getHoistedNoteId()); setShown(true); }); @@ -91,7 +91,7 @@ function RecentChangesTimeline({ groupedByDate, setShown }: { groupedByDate: Map return (
  • {formattedTime} - { !isDeleted + { notePath && !isDeleted ? : }
  • diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css new file mode 100644 index 0000000000..1a4d6f26d6 --- /dev/null +++ b/apps/client/src/widgets/dialogs/revisions.css @@ -0,0 +1,63 @@ +body.mobile .revisions-dialog { + .modal-dialog { + height: 95vh; + } + + .modal-header { + display: flex; + flex-wrap: wrap; + gap: 0.25em; + font-size: 0.9em; + } + + .modal-title { + flex-grow: 1; + width: 100%; + } + + .modal-body { + height: fit-content !important; + flex-direction: column; + padding: 0; + } + + .modal-footer { + font-size: 0.9em; + } + + .revision-list { + height: fit-content !important; + max-height: 20vh; + border-bottom: 1px solid var(--main-border-color) !important; + padding: 0 1em; + flex-shrink: 0; + } + + .modal-body > .revision-content-wrapper { + flex-grow: 1; + max-width: unset !important; + height: 100%; + margin: 0; + display: block !important; + } + + .modal-body > .revision-content-wrapper > div:first-of-type { + flex-direction: column; + } + + .revision-title { + font-size: 1rem; + } + + .revision-title-buttons { + text-align: center; + display: flex; + gap: 0.25em; + flex-wrap: wrap; + } + + .revision-content { + padding: 0.5em; + height: fit-content; + } +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 0effd8b084..322abdd3b9 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -20,6 +20,7 @@ import ActionButton from "../react/ActionButton"; import options from "../../services/options"; import { useTriliumEvent } from "../react/hooks"; import { diffWords } from "diff"; +import "./revisions.css"; export default function RevisionsDialog() { const [ note, setNote ] = useState(); @@ -137,7 +138,7 @@ export default function RevisionsDialog() { function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: RevisionItem[], onSelect: (val: string) => void, currentRevision?: RevisionItem }) { return ( - + {revisions.map((item) => )} -
    +
    diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.css b/apps/client/src/widgets/launch_bar/BookmarkButtons.css new file mode 100644 index 0000000000..b38ba59c08 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.css @@ -0,0 +1,31 @@ +.bookmark-folder-widget { + min-width: 400px; + max-height: 500px; + padding: 7px 15px 0 15px; + font-size: 1.2rem; + overflow: auto; +} + +.bookmark-folder-widget ul { + padding: 0; + list-style-type: none; +} + +.bookmark-folder-widget .note-link { + display: block; + padding: 5px 10px 5px 5px; +} + +.bookmark-folder-widget .note-link:hover { + background-color: var(--accented-background-color); + text-decoration: none; +} + +.dropdown-menu .bookmark-folder-widget a:hover:not(.disabled) { + text-decoration: none; + background-color: transparent !important; +} + +.bookmark-folder-widget li .note-link { + padding-inline-start: 35px; +} \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx new file mode 100644 index 0000000000..f3b88c7aa3 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx @@ -0,0 +1,59 @@ +import { useContext, useMemo } from "preact/hooks"; +import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { CSSProperties } from "preact"; +import type FNote from "../../entities/fnote"; +import { useChildNotes, useNoteLabelBoolean } from "../react/hooks"; +import "./BookmarkButtons.css"; +import NoteLink from "../react/NoteLink"; +import { CustomNoteLauncher } from "./GenericButtons"; + +const PARENT_NOTE_ID = "_lbBookmarks"; + +export default function BookmarkButtons() { + const { isHorizontalLayout } = useContext(LaunchBarContext); + const style = useMemo(() => ({ + display: "flex", + flexDirection: isHorizontalLayout ? "row" : "column", + contain: "none" + }), [ isHorizontalLayout ]); + const childNotes = useChildNotes(PARENT_NOTE_ID); + + return ( +
    + {childNotes?.map(childNote => )} +
    + ) +} + +function SingleBookmark({ note }: { note: FNote }) { + const [ bookmarkFolder ] = useNoteLabelBoolean(note, "bookmarkFolder"); + return bookmarkFolder + ? + : note.noteId} /> +} + +function BookmarkFolder({ note }: { note: FNote }) { + const { icon, title } = useLauncherIconAndTitle(note); + const childNotes = useChildNotes(note.noteId); + + return ( + +
    +
    + +
    + +
      + {childNotes.map(childNote => ( +
    • + +
    • + ))} +
    +
    +
    + ) +} diff --git a/apps/client/src/widgets/launch_bar/Calendar.tsx b/apps/client/src/widgets/launch_bar/Calendar.tsx new file mode 100644 index 0000000000..f081eee8c1 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/Calendar.tsx @@ -0,0 +1,221 @@ +import { useTriliumOptionInt } from "../react/hooks"; +import clsx from "clsx"; +import server from "../../services/server"; +import { TargetedMouseEvent, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { Dayjs } from "@triliumnext/commons"; +import { t } from "../../services/i18n"; + +interface DateNotesForMonth { + [date: string]: string; +} + +const DAYS_OF_WEEK = [ + t("calendar.sun"), + t("calendar.mon"), + t("calendar.tue"), + t("calendar.wed"), + t("calendar.thu"), + t("calendar.fri"), + t("calendar.sat") +]; + +interface DateRangeInfo { + weekNumbers: number[]; + dates: Dayjs[]; +} + +export interface CalendarArgs { + date: Dayjs; + todaysDate: Dayjs; + activeDate: Dayjs | null; + onDateClicked(date: string, e: TargetedMouseEvent): void; + onWeekClicked?: (week: string, e: TargetedMouseEvent) => void; + weekNotes: string[]; +} + +export default function Calendar(args: CalendarArgs) { + const [ rawFirstDayOfWeek ] = useTriliumOptionInt("firstDayOfWeek"); + const firstDayOfWeekISO = (rawFirstDayOfWeek === 0 ? 7 : rawFirstDayOfWeek); + + const date = args.date; + const firstDay = date.startOf('month'); + const firstDayISO = firstDay.isoWeekday(); + const monthInfo = getMonthInformation(date, firstDayISO, firstDayOfWeekISO); + + return ( + <> + +
    + {firstDayISO !== firstDayOfWeekISO && } + + +
    + + ) +} + +function CalendarWeekHeader({ rawFirstDayOfWeek }: { rawFirstDayOfWeek: number }) { + let localeDaysOfWeek = [...DAYS_OF_WEEK]; + const shifted = localeDaysOfWeek.splice(0, rawFirstDayOfWeek); + localeDaysOfWeek = ['', ...localeDaysOfWeek, ...shifted]; + + return ( +
    + {localeDaysOfWeek.map(dayOfWeek => {dayOfWeek})} +
    + ) +} + +function PreviousMonthDays({ date, info: { dates, weekNumbers }, ...args }: { date: Dayjs, info: DateRangeInfo } & CalendarArgs) { + const prevMonth = date.subtract(1, 'month').format('YYYY-MM'); + const [ dateNotesForPrevMonth, setDateNotesForPrevMonth ] = useState(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${prevMonth}`).then(setDateNotesForPrevMonth); + }, [ date ]); + + return ( + <> + + {dates.map(date => )} + + ) +} + +function CurrentMonthDays({ date, firstDayOfWeekISO, ...args }: { date: Dayjs, firstDayOfWeekISO: number } & CalendarArgs) { + let dateCursor = date; + const currentMonth = date.month(); + const items: VNode[] = []; + const curMonthString = date.format('YYYY-MM'); + const [ dateNotesForCurMonth, setDateNotesForCurMonth ] = useState(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${curMonthString}`).then(setDateNotesForCurMonth); + }, [ date ]); + + while (dateCursor.month() === currentMonth) { + const weekNumber = getWeekNumber(dateCursor, firstDayOfWeekISO); + if (dateCursor.isoWeekday() === firstDayOfWeekISO) { + items.push() + } + + items.push() + dateCursor = dateCursor.add(1, "day"); + } + + return items; +} + +function NextMonthDays({ date, dates, ...args }: { date: Dayjs, dates: Dayjs[] } & CalendarArgs) { + const nextMonth = date.add(1, 'month').format('YYYY-MM'); + const [ dateNotesForNextMonth, setDateNotesForNextMonth ] = useState(); + + useEffect(() => { + server.get(`special-notes/notes-for-month/${nextMonth}`).then(setDateNotesForNextMonth); + }, [ date ]); + + return dates.map(date => ( + + )); +} + +function CalendarDay({ date, dateNotesForMonth, className, activeDate, todaysDate, onDateClicked }: { date: Dayjs, dateNotesForMonth?: DateNotesForMonth, className?: string } & CalendarArgs) { + const dateString = date.local().format('YYYY-MM-DD'); + const dateNoteId = dateNotesForMonth?.[dateString]; + return ( + onDateClicked(dateString, e)} + > + + {date.date()} + + + ); +} + +function CalendarWeek({ date, weekNumber, weekNotes, onWeekClicked }: { weekNumber: number, weekNotes: string[] } & Pick) { + const localDate = date.local(); + + // Handle case where week is in between years. + let year = localDate.year(); + if (localDate.month() === 11 && weekNumber === 1) year++; + + const weekString = `${year}-W${String(weekNumber).padStart(2, '0')}`; + + if (onWeekClicked) { + return ( + onWeekClicked(weekString, e)} + >{weekNumber} + ) + } + + return ( + {weekNumber}); +} + +export function getMonthInformation(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number) { + return { + prevMonth: getPrevMonthDays(date, firstDayISO, firstDayOfWeekISO), + nextMonth: getNextMonthDays(date, firstDayOfWeekISO) + } +} + +function getPrevMonthDays(date: Dayjs, firstDayISO: number, firstDayOfWeekISO: number): DateRangeInfo { + const prevMonthLastDay = date.subtract(1, 'month').endOf('month'); + const daysToAdd = (firstDayISO - firstDayOfWeekISO + 7) % 7; + const dates: Dayjs[] = []; + + const firstDay = date.startOf('month'); + const weekNumber = getWeekNumber(firstDay, firstDayOfWeekISO); + + // Get dates from previous month + for (let i = daysToAdd - 1; i >= 0; i--) { + dates.push(prevMonthLastDay.subtract(i, 'day')); + } + + return { weekNumbers: [ weekNumber ], dates }; +} + +function getNextMonthDays(date: Dayjs, firstDayOfWeekISO: number): DateRangeInfo { + const lastDayOfMonth = date.endOf('month'); + const lastDayISO = lastDayOfMonth.isoWeekday(); + const lastDayOfUserWeek = ((firstDayOfWeekISO + 6 - 1) % 7) + 1; + const nextMonthFirstDay = date.add(1, 'month').startOf('month'); + const dates: Dayjs[] = []; + + if (lastDayISO !== lastDayOfUserWeek) { + const daysToAdd = (lastDayOfUserWeek - lastDayISO + 7) % 7; + + for (let i = 0; i < daysToAdd; i++) { + dates.push(nextMonthFirstDay.add(i, 'day')); + } + } + return { weekNumbers: [], dates }; +} + +export function getWeekNumber(date: Dayjs, firstDayOfWeekISO: number): number { + const weekStart = getWeekStartDate(date, firstDayOfWeekISO); + return weekStart.isoWeek(); +} + +function getWeekStartDate(date: Dayjs, firstDayOfWeekISO: number): Dayjs { + const currentISO = date.isoWeekday(); + const diff = (currentISO - firstDayOfWeekISO + 7) % 7; + return date.clone().subtract(diff, "day").startOf("day"); +} diff --git a/apps/client/src/stylesheets/calendar.css b/apps/client/src/widgets/launch_bar/CalendarWidget.css similarity index 93% rename from apps/client/src/stylesheets/calendar.css rename to apps/client/src/widgets/launch_bar/CalendarWidget.css index 48f01a9b83..a47d026278 100644 --- a/apps/client/src/stylesheets/calendar.css +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.css @@ -4,9 +4,12 @@ box-sizing: border-box; } +.dropdown-menu:not(.static).calendar-dropdown-menu { + padding: 0 !important; +} + .calendar-dropdown-widget { margin: 0 auto; - overflow: hidden; width: 100%; } @@ -170,4 +173,13 @@ background-color: var(--hover-item-background-color); color: var(--hover-item-text-color); text-decoration: underline; +} + +.calendar-dropdown-widget .form-control { + padding: 0; +} + +.calendar-dropdown-widget .calendar-month-selector .dropdown-menu { + left: 50%; + transform: translateX(-50%); } \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx new file mode 100644 index 0000000000..1972672232 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx @@ -0,0 +1,188 @@ +import { Dispatch, StateUpdater, useMemo, useRef, useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import { LaunchBarDropdownButton, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { Dayjs, dayjs } from "@triliumnext/commons"; +import appContext from "../../components/app_context"; +import "./CalendarWidget.css"; +import Calendar, { CalendarArgs } from "./Calendar"; +import ActionButton from "../react/ActionButton"; +import { t } from "../../services/i18n"; +import FormDropdownList from "../react/FormDropdownList"; +import FormTextBox from "../react/FormTextBox"; +import toast from "../../services/toast"; +import date_notes from "../../services/date_notes"; +import { Dropdown } from "bootstrap"; +import search from "../../services/search"; +import server from "../../services/server"; + +const MONTHS = [ + t("calendar.january"), + t("calendar.february"), + t("calendar.march"), + t("calendar.april"), + t("calendar.may"), + t("calendar.june"), + t("calendar.july"), + t("calendar.august"), + t("calendar.september"), + t("calendar.october"), + t("calendar.november"), + t("calendar.december") +]; + +export default function CalendarWidget({ launcherNote }: LauncherNoteProps) { + const { title, icon } = useLauncherIconAndTitle(launcherNote); + const [ calendarArgs, setCalendarArgs ] = useState>(); + const [ date, setDate ] = useState(); + const dropdownRef = useRef(null); + const [ enableWeekNotes, setEnableWeekNotes ] = useState(false); + const [ weekNotes, setWeekNotes ] = useState([]); + const calendarRootRef = useRef(); + + async function checkEnableWeekNotes() { + if (!calendarRootRef.current) { + const notes = await search.searchForNotes("#calendarRoot"); + if (!notes.length) return; + calendarRootRef.current = notes[0]; + } + + if (!calendarRootRef.current) return; + + const enableWeekNotes = calendarRootRef.current.hasLabel("enableWeekNote"); + setEnableWeekNotes(enableWeekNotes); + + if (enableWeekNotes) { + server.get(`attribute-values/weekNote`).then(setWeekNotes); + } + } + + return ( + { + const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote"); + const activeDate = dateNote ? dayjs(`${dateNote}T12:00:00`) : null + const todaysDate = dayjs(); + setCalendarArgs({ + activeDate, + todaysDate, + }); + setDate(dayjs(activeDate || todaysDate).startOf('month')); + try { + await checkEnableWeekNotes(); + } catch (e: unknown) { + // Non-critical. + } + }} + dropdownRef={dropdownRef} + dropdownOptions={{ + autoClose: "outside" + }} + > + {calendarArgs && date &&
    + + { + const note = await date_notes.getDayNote(date); + if (note) { + appContext.tabManager.getActiveContext()?.setNote(note.noteId); + dropdownRef.current?.hide(); + } else { + toast.showError(t("calendar.cannot_find_day_note")); + } + e.stopPropagation(); + }} + onWeekClicked={enableWeekNotes ? async (week, e) => { + const note = await date_notes.getWeekNote(week); + if (note) { + appContext.tabManager.getActiveContext()?.setNote(note.noteId); + dropdownRef.current?.hide(); + } else { + toast.showError(t("calendar.cannot_find_week_note")); + } + e.stopPropagation(); + } : undefined} + weekNotes={weekNotes} + {...calendarArgs} + /> +
    } +
    + ) +} + +interface CalendarHeaderProps { + date: Dayjs; + setDate: Dispatch>; +} + +function CalendarHeader(props: CalendarHeaderProps) { + return ( +
    + + +
    + ) +} + +function CalendarMonthSelector({ date, setDate }: CalendarHeaderProps) { + const months = useMemo(() => ( + Array.from(MONTHS.entries().map(([ index, text ]) => ({ + index: index.toString(), text + }))) + ), []); + + return ( +
    + + setDate(date.set("month", parseInt(index, 10)))} + buttonProps={{ "data-calendar-input": "month" }} + dropdownOptions={{ display: "static" }} + /> + +
    + ); +} + +function CalendarYearSelector({ date, setDate }: CalendarHeaderProps) { + return ( +
    + + { + const year = parseInt(newValue, 10); + if (!Number.isNaN(year)) { + setDate(date.set("year", year)); + } + }} + data-calendar-input="year" + /> + +
    + ) +} + +function AdjustDateButton({ date, setDate, unit, direction }: CalendarHeaderProps & { + direction: "prev" | "next", + unit: "month" | "year" +}) { + return ( + { + e.stopPropagation(); + const newDate = direction === "prev" ? date.subtract(1, unit) : date.add(1, unit); + setDate(newDate); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/GenericButtons.tsx b/apps/client/src/widgets/launch_bar/GenericButtons.tsx new file mode 100644 index 0000000000..9af7757606 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/GenericButtons.tsx @@ -0,0 +1,55 @@ +import { useCallback } from "preact/hooks"; +import appContext from "../../components/app_context"; +import FNote from "../../entities/fnote"; +import link_context_menu from "../../menus/link_context_menu"; +import { escapeHtml, isCtrlKey } from "../../services/utils"; +import { useGlobalShortcut, useNoteLabel } from "../react/hooks"; +import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; + +export function CustomNoteLauncher({ launcherNote, getTargetNoteId, getHoistedNoteId }: { + launcherNote: FNote; + getTargetNoteId: (launcherNote: FNote) => string | null | Promise; + getHoistedNoteId?: (launcherNote: FNote) => string | null; + keyboardShortcut?: string; +}) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => { + if (evt.which === 3) { + return; + } + + const targetNoteId = await getTargetNoteId(launcherNote); + if (!targetNoteId) return; + + const hoistedNoteIdWithDefault = getHoistedNoteId?.(launcherNote) || appContext.tabManager.getActiveContext()?.hoistedNoteId; + const ctrlKey = isCtrlKey(evt); + + if ((evt.which === 1 && ctrlKey) || evt.which === 2) { + const activate = evt.shiftKey ? true : false; + await appContext.tabManager.openInNewTab(targetNoteId, hoistedNoteIdWithDefault, activate); + } else { + await appContext.tabManager.openInSameTab(targetNoteId); + } + }, [ launcherNote, getTargetNoteId, getHoistedNoteId ]); + + // Keyboard shortcut. + const [ shortcut ] = useNoteLabel(launcherNote, "keyboardShortcut"); + useGlobalShortcut(shortcut, launch); + + return ( + { + evt.preventDefault(); + const targetNoteId = await getTargetNoteId(launcherNote); + if (targetNoteId) { + link_context_menu.openContextMenu(targetNoteId, evt); + } + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx new file mode 100644 index 0000000000..f9ea51c571 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import { dynamicRequire, isElectron } from "../../services/utils"; +import { LaunchBarActionButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import type { WebContents } from "electron"; +import contextMenu, { MenuCommandItem } from "../../menus/context_menu"; +import tree from "../../services/tree"; +import link from "../../services/link"; + +interface HistoryNavigationProps { + launcherNote: FNote; + command: "backInNoteHistory" | "forwardInNoteHistory"; +} + +const HISTORY_LIMIT = 20; + +export default function HistoryNavigationButton({ launcherNote, command }: HistoryNavigationProps) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + const webContentsRef = useRef(null); + + useEffect(() => { + if (isElectron()) { + const webContents = dynamicRequire("@electron/remote").getCurrentWebContents(); + // without this, the history is preserved across frontend reloads + webContents?.clearHistory(); + webContentsRef.current = webContents; + } + }, []); + + return ( + { + e.preventDefault(); + + const webContents = webContentsRef.current; + if (!webContents || webContents.navigationHistory.length() < 2) { + return; + } + + let items: MenuCommandItem[] = []; + + const history = webContents.navigationHistory.getAllEntries(); + const activeIndex = webContents.navigationHistory.getActiveIndex(); + + for (const idx in history) { + const { notePath } = link.parseNavigationStateFromUrl(history[idx].url); + if (!notePath) continue; + + const title = await tree.getNotePathTitle(notePath); + + items.push({ + title, + command: idx, + uiIcon: + parseInt(idx) === activeIndex + ? "bx bx-radio-circle-marked" // compare with type coercion! + : parseInt(idx) < activeIndex + ? "bx bx-left-arrow-alt" + : "bx bx-right-arrow-alt" + }); + } + + items.reverse(); + + if (items.length > HISTORY_LIMIT) { + items = items.slice(0, HISTORY_LIMIT); + } + + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items, + selectMenuItemHandler: (item: MenuCommandItem) => { + if (item && item.command && webContents) { + const idx = parseInt(item.command, 10); + webContents.navigationHistory.goToIndex(idx); + } + } + }); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx new file mode 100644 index 0000000000..26a502a8a2 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -0,0 +1,128 @@ +import { useCallback, useLayoutEffect, useState } from "preact/hooks"; +import FNote from "../../entities/fnote"; +import froca from "../../services/froca"; +import { isDesktop, isMobile } from "../../services/utils"; +import CalendarWidget from "./CalendarWidget"; +import SpacerWidget from "./SpacerWidget"; +import BookmarkButtons from "./BookmarkButtons"; +import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget"; +import SyncStatus from "./SyncStatus"; +import HistoryNavigationButton from "./HistoryNavigation"; +import { AiChatButton, CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions"; +import { useTriliumEvent } from "../react/hooks"; +import { onWheelHorizontalScroll } from "../widget_utils"; +import { LaunchBarContext } from "./launch_bar_widgets"; + +export default function LauncherContainer({ isHorizontalLayout }: { isHorizontalLayout: boolean }) { + const childNotes = useLauncherChildNotes(); + + return ( +
    { + if ((e.target as HTMLElement).closest(".dropdown-menu")) return; + onWheelHorizontalScroll(e); + } : undefined} + > + + {childNotes?.map(childNote => { + if (childNote.type !== "launcher") { + throw new Error(`Note '${childNote.noteId}' '${childNote.title}' is not a launcher even though it's in the launcher subtree`); + } + + if (!isDesktop() && childNote.isLabelTruthy("desktopOnly")) { + return false; + } + + return + })} + +
    + ) +} + +function Launcher({ note, isHorizontalLayout }: { note: FNote, isHorizontalLayout: boolean }) { + const launcherType = note.getLabelValue("launcherType"); + if (glob.TRILIUM_SAFE_MODE && launcherType === "customWidget") return; + + switch (launcherType) { + case "command": + return ; + case "note": + return ; + case "script": + return ; + case "customWidget": + return ; + case "builtinWidget": + return initBuiltinWidget(note, isHorizontalLayout); + default: + throw new Error(`Unrecognized launcher type '${launcherType}' for launcher '${note.noteId}' title '${note.title}'`); + } +} + +function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { + const builtinWidget = note.getLabelValue("builtinWidget"); + switch (builtinWidget) { + case "calendar": + return + case "spacer": + // || has to be inside since 0 is a valid value + const baseSize = parseInt(note.getLabelValue("baseSize") || "40"); + const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100"); + + return ; + case "bookmarks": + return ; + case "protectedSession": + return ; + case "syncStatus": + return ; + case "backInHistoryButton": + return + case "forwardInHistoryButton": + return + case "todayInJournal": + return + case "quickSearch": + return + case "aiChatLauncher": + return + default: + throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); + } +} + +function useLauncherChildNotes() { + const [ visibleLaunchersRoot, setVisibleLaunchersRoot ] = useState(); + const [ childNotes, setChildNotes ] = useState(); + + // Load the root note. + useLayoutEffect(() => { + const visibleLaunchersRootId = isMobile() ? "_lbMobileVisibleLaunchers" : "_lbVisibleLaunchers"; + froca.getNote(visibleLaunchersRootId, true).then(setVisibleLaunchersRoot); + }, []); + + // Load the children. + const refresh = useCallback(() => { + if (!visibleLaunchersRoot) return; + visibleLaunchersRoot.getChildNotes().then(setChildNotes); + }, [ visibleLaunchersRoot, setChildNotes ]); + useLayoutEffect(refresh, [ visibleLaunchersRoot ]); + + // React to position changes. + useTriliumEvent("entitiesReloaded", ({loadResults}) => { + if (loadResults.getBranchRows().find((branch) => branch.parentNoteId && froca.getNoteFromCache(branch.parentNoteId)?.isLaunchBarConfig())) { + refresh(); + } + }); + + return childNotes; +} diff --git a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx new file mode 100644 index 0000000000..a0c379b224 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx @@ -0,0 +1,160 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from "preact/hooks"; +import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget, useTriliumOptionBool } from "../react/hooks"; +import { ParentComponent } from "../react/react_utils"; +import BasicWidget from "../basic_widget"; +import FNote from "../../entities/fnote"; +import QuickSearchWidget from "../quick_search"; +import { getErrorMessage, isMobile } from "../../services/utils"; +import date_notes from "../../services/date_notes"; +import { CustomNoteLauncher } from "./GenericButtons"; +import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import dialog from "../../services/dialog"; +import { t } from "../../services/i18n"; +import appContext, { CommandNames } from "../../components/app_context"; +import toast from "../../services/toast"; + +export function CommandButton({ launcherNote }: LauncherNoteProps) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + const [ command ] = useNoteLabel(launcherNote, "command"); + + return command && ( + + ) +} + +// we're intentionally displaying the launcher title and icon instead of the target, +// e.g. you want to make launchers to 2 mermaid diagrams which both have mermaid icon (ok), +// but on the launchpad you want them distinguishable. +// for titles, the note titles may follow a different scheme than maybe desirable on the launchpad +// another reason is the discrepancy between what user sees on the launchpad and in the config (esp. icons). +// The only downside is more work in setting up the typical case +// where you actually want to have both title and icon in sync, but for those cases there are bookmarks +export function NoteLauncher({ launcherNote, ...restProps }: { launcherNote: FNote, hoistedNoteId?: string }) { + return ( + { + const targetNoteId = launcherNote.getRelationValue("target"); + if (!targetNoteId) { + dialog.info(t("note_launcher.this_launcher_doesnt_define_target_note")); + return null; + } + return targetNoteId; + }} + getHoistedNoteId={launcherNote => launcherNote.getRelationValue("hoistedNote")} + {...restProps} + /> + ); +} + +export function ScriptLauncher({ launcherNote }: LauncherNoteProps) { + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + const launch = useCallback(async () => { + if (launcherNote.isLabelTruthy("scriptInLauncherContent")) { + await launcherNote.executeScript(); + } else { + const script = await launcherNote.getRelationTarget("script"); + if (script) { + await script.executeScript(); + } + } + }, [ launcherNote ]); + + // Keyboard shortcut. + const [ shortcut ] = useNoteLabel(launcherNote, "keyboardShortcut"); + useGlobalShortcut(shortcut, launch); + + return ( + + ) +} + +export function AiChatButton({ launcherNote }: LauncherNoteProps) { + const [ aiEnabled ] = useTriliumOptionBool("aiEnabled"); + const { icon, title } = useLauncherIconAndTitle(launcherNote); + + return aiEnabled && ( + + ) +} + +export function TodayLauncher({ launcherNote }: LauncherNoteProps) { + return ( + { + const todayNote = await date_notes.getTodayNote(); + return todayNote?.noteId ?? null; + }} + /> + ); +} + +export function QuickSearchLauncherWidget() { + const { isHorizontalLayout } = useContext(LaunchBarContext); + const widget = useMemo(() => new QuickSearchWidget(), []); + const parentComponent = useContext(ParentComponent) as BasicWidget | null; + const isEnabled = isHorizontalLayout && !isMobile(); + parentComponent?.contentSized(); + + return ( +
    + {isEnabled && } +
    + ) +} + +export function CustomWidget({ launcherNote }: LauncherNoteProps) { + const [ widgetNote ] = useNoteRelationTarget(launcherNote, "widget"); + const [ widget, setWidget ] = useState(); + const parentComponent = useContext(ParentComponent) as BasicWidget | null; + parentComponent?.contentSized(); + + useEffect(() => { + (async function() { + let widget: BasicWidget; + try { + widget = await widgetNote?.executeScript(); + } catch (e) { + toast.showError(t("toast.bundle-error.message", { + id: widgetNote?.noteId, + title: widgetNote?.title, + message: getErrorMessage(e) + })); + return; + } + + if (widgetNote && widget instanceof BasicWidget) { + widget._noteId = widgetNote.noteId; + } + setWidget(widget); + })(); + }, [ widgetNote ]); + + return ( +
    + {widget && } +
    + ) +} + +export function LegacyWidgetRenderer({ widget }: { widget: BasicWidget }) { + const [ widgetEl ] = useLegacyWidget(() => widget, { + noteContext: appContext.tabManager.getActiveContext() ?? undefined + }); + + return widgetEl; +} diff --git a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx new file mode 100644 index 0000000000..539643d4f9 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx @@ -0,0 +1,33 @@ +import { useState } from "preact/hooks"; +import protected_session_holder from "../../services/protected_session_holder"; +import { LaunchBarActionButton } from "./launch_bar_widgets"; +import { useTriliumEvent } from "../react/hooks"; +import { t } from "../../services/i18n"; + +export default function ProtectedSessionStatusWidget() { + const protectedSessionAvailable = useProtectedSessionAvailable(); + + return ( + protectedSessionAvailable ? ( + + ) : ( + + ) + ) +} + +function useProtectedSessionAvailable() { + const [ protectedSessionAvailable, setProtectedSessionAvailable ] = useState(protected_session_holder.isProtectedSessionAvailable()); + useTriliumEvent("protectedSessionStarted", () => { + setProtectedSessionAvailable(protected_session_holder.isProtectedSessionAvailable()); + }); + return protectedSessionAvailable; +} diff --git a/apps/client/src/widgets/launch_bar/SpacerWidget.tsx b/apps/client/src/widgets/launch_bar/SpacerWidget.tsx new file mode 100644 index 0000000000..5f89369c25 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SpacerWidget.tsx @@ -0,0 +1,35 @@ +import appContext, { CommandNames } from "../../components/app_context"; +import contextMenu from "../../menus/context_menu"; +import { t } from "../../services/i18n"; +import { isMobile } from "../../services/utils"; + +interface SpacerWidgetProps { + baseSize?: number; + growthFactor?: number; +} + +export default function SpacerWidget({ baseSize, growthFactor }: SpacerWidgetProps) { + return ( +
    { + e.preventDefault(); + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") }], + selectMenuItemHandler: ({ command }) => { + if (command) { + appContext.triggerCommand(command); + } + } + }); + }} + /> + ) +} diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.css b/apps/client/src/widgets/launch_bar/SyncStatus.css new file mode 100644 index 0000000000..dc9795e6aa --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SyncStatus.css @@ -0,0 +1,26 @@ +.sync-status { + box-sizing: border-box; +} + +.sync-status .sync-status-icon { + display: inline-block; + position: relative; + top: -5px; + font-size: 110%; +} + +.sync-status .sync-status-sub-icon { + font-size: 40%; + position: absolute; + inset-inline-start: 0; + top: 16px; +} + +.sync-status .sync-status-icon span { + border: none !important; +} + +.sync-status-icon:not(.sync-status-in-progress):hover { + background-color: var(--hover-item-background-color); + cursor: pointer; +} \ No newline at end of file diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.tsx b/apps/client/src/widgets/launch_bar/SyncStatus.tsx new file mode 100644 index 0000000000..3cf8ab7779 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/SyncStatus.tsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import "./SyncStatus.css"; +import { t } from "../../services/i18n"; +import clsx from "clsx"; +import { escapeQuotes } from "../../services/utils"; +import { useStaticTooltip, useTriliumOption } from "../react/hooks"; +import sync from "../../services/sync"; +import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws"; +import { WebSocketMessage } from "@triliumnext/commons"; + +type SyncState = "unknown" | "in-progress" + | "connected-with-changes" | "connected-no-changes" + | "disconnected-with-changes" | "disconnected-no-changes"; + +interface StateMapping { + title: string; + icon: string; + hasChanges?: boolean; +} + +const STATE_MAPPINGS: Record = { + unknown: { + title: t("sync_status.unknown"), + icon: "bx bx-time" + }, + "connected-with-changes": { + title: t("sync_status.connected_with_changes"), + icon: "bx bx-wifi", + hasChanges: true + }, + "connected-no-changes": { + title: t("sync_status.connected_no_changes"), + icon: "bx bx-wifi" + }, + "disconnected-with-changes": { + title: t("sync_status.disconnected_with_changes"), + icon: "bx bx-wifi-off", + hasChanges: true + }, + "disconnected-no-changes": { + title: t("sync_status.disconnected_no_changes"), + icon: "bx bx-wifi-off" + }, + "in-progress": { + title: t("sync_status.in_progress"), + icon: "bx bx-analyse bx-spin" + } +}; + +export default function SyncStatus() { + const syncState = useSyncStatus(); + const { title, icon, hasChanges } = STATE_MAPPINGS[syncState]; + const spanRef = useRef(null); + const [ syncServerHost ] = useTriliumOption("syncServerHost"); + useStaticTooltip(spanRef, { + html: true + // TODO: Placement + }); + + return (syncServerHost && +
    +
    + { + if (syncState === "in-progress") return; + sync.syncNow(); + }} + > + {hasChanges && ( + + )} + +
    +
    + ) +} + +function useSyncStatus() { + const [ syncState, setSyncState ] = useState("unknown"); + + useEffect(() => { + let lastSyncedPush: number; + + function onMessage(message: WebSocketMessage) { + // First, read last synced push. + if ("lastSyncedPush" in message) { + lastSyncedPush = message.lastSyncedPush; + } else if ("data" in message && message.data && "lastSyncedPush" in message.data && lastSyncedPush !== undefined) { + lastSyncedPush = message.data.lastSyncedPush; + } + + // Determine if all changes were pushed. + const allChangesPushed = lastSyncedPush === ws.getMaxKnownEntityChangeSyncId(); + + let syncState: SyncState = "unknown"; + if (message.type === "sync-pull-in-progress") { + syncState = "in-progress"; + } else if (message.type === "sync-push-in-progress") { + syncState = "in-progress"; + } else if (message.type === "sync-finished") { + syncState = allChangesPushed ? "connected-no-changes" : "connected-with-changes"; + } else if (message.type === "sync-failed") { + syncState = allChangesPushed ? "disconnected-no-changes" : "disconnected-with-changes"; + } else if (message.type === "frontend-update") { + lastSyncedPush = message.data.lastSyncedPush; + } + setSyncState(syncState); + } + + subscribeToMessages(onMessage); + return () => unsubscribeToMessage(onMessage); + }, []); + + return syncState; +} diff --git a/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx new file mode 100644 index 0000000000..e3f219c932 --- /dev/null +++ b/apps/client/src/widgets/launch_bar/launch_bar_widgets.tsx @@ -0,0 +1,67 @@ +import { createContext } from "preact"; +import FNote from "../../entities/fnote"; +import { escapeHtml } from "../../services/utils"; +import ActionButton, { ActionButtonProps } from "../react/ActionButton"; +import Dropdown, { DropdownProps } from "../react/Dropdown"; +import { useNoteLabel, useNoteProperty } from "../react/hooks"; +import Icon from "../react/Icon"; +import { useContext } from "preact/hooks"; + +export const LaunchBarContext = createContext<{ + isHorizontalLayout: boolean; +}>({ + isHorizontalLayout: false +}) + +export interface LauncherNoteProps { + /** The corresponding {@link FNote} of type {@code launcher} in the hidden subtree of this launcher. Generally this launcher note holds information about the launcher via labels and relations, but also the title and the icon of the launcher. Not to be confused with the target note, which is specific to some launchers. */ + launcherNote: FNote; +} + +export function LaunchBarActionButton(props: Omit) { + const { isHorizontalLayout } = useContext(LaunchBarContext); + + return ( + + ) +} + +export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick & { icon: string }) { + const { isHorizontalLayout } = useContext(LaunchBarContext); + + return ( + } + titlePosition={isHorizontalLayout ? "bottom" : "right"} + titleOptions={{ animation: false }} + dropdownOptions={{ + ...dropdownOptions, + popperConfig: { + placement: isHorizontalLayout ? "bottom" : "right" + } + }} + {...props} + >{children} + ) +} + +export function useLauncherIconAndTitle(note: FNote) { + const title = useNoteProperty(note, "title"); + + // React to changes. + useNoteLabel(note, "iconClass"); + useNoteLabel(note, "workspaceIconClass"); + + return { + icon: note.getIcon(), + title: escapeHtml(title ?? "") + }; +} diff --git a/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx b/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx index 255ac8c99e..765bb4c31e 100644 --- a/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx +++ b/apps/client/src/widgets/mobile_widgets/mobile_detail_menu.tsx @@ -1,12 +1,13 @@ import { useContext } from "preact/hooks"; -import appContext from "../../components/app_context"; -import contextMenu from "../../menus/context_menu"; +import appContext, { CommandMappings } from "../../components/app_context"; +import contextMenu, { MenuItem } from "../../menus/context_menu"; import branches from "../../services/branches"; import { t } from "../../services/i18n"; import note_create from "../../services/note_create"; import tree from "../../services/tree"; import ActionButton from "../react/ActionButton"; import { ParentComponent } from "../react/react_utils"; +import BasicWidget from "../basic_widget"; export default function MobileDetailMenu() { const parentComponent = useContext(ParentComponent); @@ -16,17 +17,33 @@ export default function MobileDetailMenu() { icon="bx bx-dots-vertical-rounded" text="" onClick={(e) => { - const note = appContext.tabManager.getActiveContextNote(); + const ntxId = (parentComponent as BasicWidget | null)?.getClosestNtxId(); + if (!ntxId) return; - contextMenu.show<"insertChildNote" | "delete" | "showRevisions">({ + const noteContext = appContext.tabManager.getNoteContextById(ntxId); + const subContexts = noteContext.getMainContext().getSubContexts(); + const isMainContext = noteContext?.isMainContext(); + const note = noteContext.note; + + const items: (MenuItem)[] = [ + { title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" }, + { title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" }, + { kind: "separator" }, + { title: t("mobile_detail_menu.note_revisions"), command: "showRevisions", uiIcon: "bx bx-history" }, + { kind: "separator" }, + subContexts.length < 2 && { title: t("create_pane_button.create_new_split"), command: "openNewNoteSplit", uiIcon: "bx bx-dock-right" }, + !isMainContext && { title: t("close_pane_button.close_this_pane"), command: "closeThisNoteSplit", uiIcon: "bx bx-x" } + ].filter(i => !!i) as MenuItem[]; + + const lastItem = items.at(-1); + if (lastItem && "kind" in lastItem && lastItem.kind === "separator") { + items.pop(); + } + + contextMenu.show({ x: e.pageX, y: e.pageY, - items: [ - { title: t("mobile_detail_menu.insert_child_note"), command: "insertChildNote", uiIcon: "bx bx-plus", enabled: note?.type !== "search" }, - { title: t("mobile_detail_menu.delete_this_note"), command: "delete", uiIcon: "bx bx-trash", enabled: note?.noteId !== "root" }, - { kind: "separator" }, - { title: t("mobile_detail_menu.note_revisions"), command: "showRevisions", uiIcon: "bx bx-history" } - ], + items, selectMenuItemHandler: async ({ command }) => { if (command === "insertChildNote") { note_create.createNote(appContext.tabManager.getActiveContextNotePath() ?? undefined); @@ -46,7 +63,7 @@ export default function MobileDetailMenu() { parentComponent.triggerCommand("setActiveScreen", { screen: "tree" }); } } else if (command && parentComponent) { - parentComponent.triggerCommand(command); + parentComponent.triggerCommand(command, { ntxId }); } }, forcePositionOnMobile: true diff --git a/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx b/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx index d22f3df8c9..8e689954b0 100644 --- a/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx +++ b/apps/client/src/widgets/mobile_widgets/toggle_sidebar_button.tsx @@ -1,18 +1,19 @@ -import { useContext } from "preact/hooks"; import ActionButton from "../react/ActionButton"; -import { ParentComponent } from "../react/react_utils"; import { t } from "../../services/i18n"; +import { useNoteContext } from "../react/hooks"; export default function ToggleSidebarButton() { - const parentComponent = useContext(ParentComponent); + const { noteContext, parentComponent } = useNoteContext(); return ( - parentComponent?.triggerCommand("setActiveScreen", { - screen: "tree" - })} - /> +
    + { noteContext?.isMainContext() && parentComponent?.triggerCommand("setActiveScreen", { + screen: "tree" + })} + />} +
    ) } diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx index 1c503c3637..12e15202d2 100644 --- a/apps/client/src/widgets/note_map/NoteMap.tsx +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -95,13 +95,13 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { if (!graphRef.current || !notesAndRelationsRef.current) return; graphRef.current.d3Force("link")?.distance(linkDistance); graphRef.current.graphData(notesAndRelationsRef.current); - }, [ linkDistance ]); + }, [ linkDistance, mapType ]); // React to container size useEffect(() => { if (!containerSize || !graphRef.current) return; graphRef.current.width(containerSize.width).height(containerSize.height); - }, [ containerSize?.width, containerSize?.height ]); + }, [ containerSize?.width, containerSize?.height, mapType ]); // Fixing nodes when dragged. useEffect(() => { @@ -114,7 +114,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { node.fy = undefined; } }) - }, [ fixNodes ]); + }, [ fixNodes, mapType ]); return (
    diff --git a/apps/client/src/widgets/note_tree.ts b/apps/client/src/widgets/note_tree.ts index 49eb4dcac5..c5ee99d86f 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -508,7 +508,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { (data.hitMode === "over" && node.data.noteType === "search") || (["after", "before"].includes(data.hitMode) && (node.data.noteId === hoistedNoteService.getHoistedNoteId() || node.getParent().data.noteType === "search")) ) { - await dialogService.info("Dropping notes into this location is not allowed."); + await dialogService.info(t("note_tree.dropping-not-allowed")); return; } @@ -574,6 +574,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { .loadSearchNote(noteId) .then(() => { const note = froca.getNoteFromCache(noteId); + if (!note) return []; let childNoteIds = note.getChildNoteIds(); @@ -585,6 +586,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }) .then(() => { const note = froca.getNoteFromCache(noteId); + if (!note) return []; return this.prepareChildren(note); }); @@ -740,7 +742,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const node = $.ui.fancytree.getNode(e as unknown as Event); const note = froca.getNoteFromCache(node.data.noteId); - if (note.isLaunchBarConfig()) { + if (note?.isLaunchBarConfig()) { import("../menus/launcher_context_menu.js").then(({ default: LauncherContextMenu }) => { const launcherContextMenu = new LauncherContextMenu(this, node); launcherContextMenu.show(e); @@ -775,7 +777,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { if (hideArchivedNotes) { const note = branch.getNoteFromCache(); - if (note.hasLabel("archived")) { + if (!note || note.hasLabel("archived")) { continue; } } @@ -1754,7 +1756,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { for (const nodeToDuplicate of nodesToDuplicate) { const note = froca.getNoteFromCache(nodeToDuplicate.data.noteId); - if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) { + if (note?.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) { continue; } diff --git a/apps/client/src/widgets/promoted_attributes.ts b/apps/client/src/widgets/promoted_attributes.ts deleted file mode 100644 index 5853e1d87e..0000000000 --- a/apps/client/src/widgets/promoted_attributes.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { t } from "../services/i18n.js"; -import server from "../services/server.js"; -import ws from "../services/ws.js"; -import treeService from "../services/tree.js"; -import noteAutocompleteService from "../services/note_autocomplete.js"; -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import attributeService from "../services/attributes.js"; -import options from "../services/options.js"; -import utils from "../services/utils.js"; -import type FNote from "../entities/fnote.js"; -import type { Attribute } from "../services/attribute_parser.js"; -import type FAttribute from "../entities/fattribute.js"; -import type { EventData } from "../components/app_context.js"; - -const TPL = /*html*/` -`; - -// TODO: Deduplicate -interface AttributeResult { - attributeId: string; -} - -export default class PromotedAttributesWidget extends NoteContextAwareWidget { - - private $container!: JQuery; - - get name() { - return "promotedAttributes"; - } - - get toggleCommand() { - return "toggleRibbonTabPromotedAttributes"; - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - this.$container = this.$widget.find(".promoted-attributes-container"); - } - - getTitle(note: FNote) { - const promotedDefAttrs = note.getPromotedDefinitionAttributes(); - - if (promotedDefAttrs.length === 0) { - return { show: false }; - } - - return { - show: true, - activate: options.is("promotedAttributesOpenInRibbon"), - title: t("promoted_attributes.promoted_attributes"), - icon: "bx bx-table" - }; - } - - async refreshWithNote(note: FNote) { - this.$container.empty(); - - const promotedDefAttrs = note.getPromotedDefinitionAttributes(); - const ownedAttributes = note.getOwnedAttributes(); - // attrs are not resorted if position changes after the initial load - // promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs - // the order of attributes is important as well - ownedAttributes.sort((a, b) => a.position - b.position); - - if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") { - this.toggleInt(false); - return; - } - - const $cells: JQuery[] = []; - - for (const definitionAttr of promotedDefAttrs) { - const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation"; - const valueName = definitionAttr.name.substr(valueType.length + 1); - - let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[]; - - if (valueAttrs.length === 0) { - valueAttrs.push({ - attributeId: "", - type: valueType, - name: valueName, - value: "" - }); - } - - if (definitionAttr.getDefinition().multiplicity === "single") { - valueAttrs = valueAttrs.slice(0, 1); - } - - for (const valueAttr of valueAttrs) { - const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName); - - if ($cell) { - $cells.push($cell); - } - } - } - - // we replace the whole content in one step, so there can't be any race conditions - // (previously we saw promoted attributes doubling) - this.$container.empty().append(...$cells); - this.toggleInt(true); - } - - async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) { - const definition = definitionAttr.getDefinition(); - const id = `value-${valueAttr.attributeId}`; - - const $input = $("") - .prop("tabindex", 200 + definitionAttr.position) - .prop("id", id) - .attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one - .attr("data-attribute-type", valueAttr.type) - .attr("data-attribute-name", valueAttr.name) - .prop("value", valueAttr.value) - .prop("placeholder", t("promoted_attributes.unset-field-placeholder")) - .addClass("form-control") - .addClass("promoted-attribute-input") - .on("change", (event) => this.promotedAttributeChanged(event)); - - const $actionCell = $("
    "); - const $multiplicityCell = $("").addClass("multiplicity").attr("nowrap", "true"); - - const $wrapper = $('