Merge remote-tracking branch 'old/develop'

This commit is contained in:
Elian Doran
2025-06-21 17:22:40 +03:00
3998 changed files with 295244 additions and 382135 deletions

View File

@@ -1,7 +0,0 @@
.git
.idea
/bin
/dist
/docs
/npm-debug.log
node_modules

26
.editorconfig Normal file
View File

@@ -0,0 +1,26 @@
root = true
[*.{js,ts}]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.sh]
end_of_line = lf
[{server,translation}.json]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.yml]
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

1
.env Normal file
View File

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

21
.gitattributes vendored Normal file
View File

@@ -0,0 +1,21 @@
# Mark files as auto-generated to simplify reviews.
package-lock.json linguist-generated=true
**/package-lock.json linguist-generated=true
apps/server/src/assets/doc_notes/en/User[[:space:]]Guide/** linguist-generated
# Ignore from GitHub language stats.
apps/server/src/assets/doc_notes/en/User[[:space:]]Guide/**/*.html eol=lf
apps/server/src/assets/doc_notes/** linguist-vendored=true
apps/edit-docs/demo/** linguist-vendored=true
docs/** linguist-vendored=true
# Normalize line endings.
docs/**/*.md eol=lf
docs/**/*.json eol=lf
demo/**/*.html eol=lf
demo/**/*.json eol=lf
demo/**/*.svg eol=lf
demo/**/*.txt eol=lf
demo/**/*.js eol=lf
demo/**/*.css eol=lf
*.sh eol=lf

4
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,4 @@
# These are supported funding model platforms # These are supported funding model platforms
github: [zadam] github: [eliandoran]
custom: ["https://paypal.me/za4am"] custom: ["https://paypal.me/eliandoran"]

View File

@@ -1,13 +1,18 @@
name: Bug Report name: Bug Report
description: Report a bug description: Report a bug
title: "(Bug report) " type: "Bug"
labels: "Type: Bug"
body: body:
- type: textarea
attributes:
label: Description
description: A clear and concise description of the bug and any additional information.
validations:
required: true
- type: input - type: input
attributes: attributes:
label: Trilium Version label: TriliumNext Version
description: What version of Trilium are you using? description: What version of TriliumNext are you using?
placeholder: 0.57.0-beta placeholder: 0.90.0-beta
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@@ -24,7 +29,7 @@ body:
- type: dropdown - type: dropdown
attributes: attributes:
label: What is your setup? label: What is your setup?
description: https://github.com/zadam/trilium/wiki#choose-the-setup description: https://triliumnext.github.io/Docs/Wiki/quick-start.html
options: options:
- Local (no sync) - Local (no sync)
- Local + server sync - Local + server sync
@@ -38,15 +43,9 @@ body:
placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04" placeholder: "e.g. Windows 10 version 1909, macOS Catalina 10.15.7, or Ubuntu 20.04"
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Description
description: A clear and concise description of the bug and any additional information.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Error logs label: Error logs
description: Please provide error logs, see [wiki page](https://github.com/zadam/trilium/wiki/Error-logs) for instructions on how to submit them. description: Please provide error logs, see [wiki page](https://triliumnext.github.io/Docs/Wiki/error-logs.html) for instructions on how to submit them.
validations: validations:
required: false required: false

View File

@@ -1,12 +1,11 @@
name: Feature Request name: Feature Request
description: Ask for a new feature to be added description: Ask for a new feature to be added
title: "(Feature request) " type: "Feature"
labels: "Type: Enhancement"
body: body:
- type: textarea - type: textarea
attributes: attributes:
label: Describe feature label: Describe feature
description: A clear and concise description of what you want to be added.. description: A clear and concise description of what you want to be added.
validations: validations:
required: true required: true
- type: textarea - type: textarea

10
.github/ISSUE_TEMPLATE/task.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
name: Task
description: Create a new Task
type: "Task"
body:
- type: textarea
attributes:
label: Describe Task
description: A clear and concise description of what the task is about.
validations:
required: true

View File

@@ -0,0 +1,164 @@
name: "Build Electron App"
description: "Builds and packages the Electron app for different platforms"
inputs:
os:
description: "One of the supported platforms: macos, linux, windows"
required: true
arch:
description: "The architecture to build for: x64, arm64"
required: true
shell:
description: "Which shell to use"
required: true
forge_platform:
description: "The --platform to pass to Electron Forge"
required: true
runs:
using: composite
steps:
# Certificate setup
- name: Import Apple certificates
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
keychain: build-app-${{ github.run_id }}
keychain-password: ${{ github.run_id }}
- name: Install Installer certificate
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
keychain: build-installer-${{ github.run_id }}
keychain-password: ${{ github.run_id }}
- name: Verify certificates
if: inputs.os == 'macos'
shell: ${{ inputs.shell }}
run: |
echo "Available signing identities in app keychain:"
security find-identity -v -p codesigning build-app-${{ github.run_id }}.keychain
echo "Available signing identities in installer keychain:"
security find-identity -v -p codesigning build-installer-${{ github.run_id }}.keychain
# Make the keychains searchable
security list-keychains -d user -s build-app-${{ github.run_id }}.keychain build-installer-${{ github.run_id }}.keychain $(security list-keychains -d user | tr -d '"')
security default-keychain -s build-app-${{ github.run_id }}.keychain
security unlock-keychain -p ${{ github.run_id }} build-app-${{ github.run_id }}.keychain
security unlock-keychain -p ${{ github.run_id }} build-installer-${{ github.run_id }}.keychain
security set-keychain-settings -t 3600 -l build-app-${{ github.run_id }}.keychain
security set-keychain-settings -t 3600 -l build-installer-${{ github.run_id }}.keychain
- name: Set up Python and other macOS dependencies
if: ${{ inputs.os == 'macos' }}
shell: ${{ inputs.shell }}
run: |
brew install python-setuptools
brew install create-dmg
- name: Install dependencies for RPM and Flatpak package building
if: ${{ inputs.os == 'linux' }}
shell: ${{ inputs.shell }}
run: |
sudo apt-get update && sudo apt-get install rpm flatpak-builder elfutils
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
FLATPAK_ARCH=$(if [[ ${{ inputs.arch }} = 'arm64' ]]; then echo 'aarch64'; else echo 'x86_64'; fi)
FLATPAK_VERSION='24.08'
flatpak install --user --no-deps --arch $FLATPAK_ARCH --assumeyes runtime/org.freedesktop.Platform/$FLATPAK_ARCH/$FLATPAK_VERSION runtime/org.freedesktop.Sdk/$FLATPAK_ARCH/$FLATPAK_VERSION org.electronjs.Electron2.BaseApp/$FLATPAK_ARCH/$FLATPAK_VERSION
- name: Update build info
shell: ${{ inputs.shell }}
run: npm run chore:update-build-info
# Critical debugging configuration
- name: Run electron-forge build with enhanced logging
shell: ${{ inputs.shell }}
env:
# Pass through required environment variables for signing and notarization
APPLE_TEAM_ID: ${{ env.APPLE_TEAM_ID }}
APPLE_ID: ${{ env.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ env.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ env.WINDOWS_SIGN_EXECUTABLE }}
TRILIUM_ARTIFACT_NAME_HINT: TriliumNextNotes-${{ github.ref_name }}-${{ inputs.os }}-${{ inputs.arch }}
run: pnpm nx --project=desktop electron-forge:make -- --arch=${{ inputs.arch }} --platform=${{ inputs.forge_platform }}
# Add DMG signing step
- name: Sign DMG
if: inputs.os == 'macos'
shell: ${{ inputs.shell }}
run: |
echo "Signing DMG file..."
dmg_file=$(find ./apps/desktop/dist -name "*.dmg" -print -quit)
if [ -n "$dmg_file" ]; then
echo "Found DMG: $dmg_file"
# Get the first valid signing identity from the keychain
SIGNING_IDENTITY=$(security find-identity -v -p codesigning build-app-${{ github.run_id }}.keychain | grep "Developer ID Application" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$SIGNING_IDENTITY" ]; then
echo "Error: No valid Developer ID Application certificate found in keychain"
exit 1
fi
echo "Using signing identity: $SIGNING_IDENTITY"
# Sign the DMG
codesign --force --sign "$SIGNING_IDENTITY" --options runtime --timestamp "$dmg_file"
# Notarize the DMG
xcrun notarytool submit "$dmg_file" --apple-id "$APPLE_ID" --password "$APPLE_ID_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
# Staple the notarization ticket
xcrun stapler staple "$dmg_file"
else
echo "No DMG found to sign"
fi
- name: Verify code signing
if: inputs.os == 'macos'
shell: ${{ inputs.shell }}
run: |
echo "Verifying code signing for all artifacts..."
# First check the .app bundle
echo "Looking for .app bundle..."
app_bundle=$(find ./apps/desktop/dist -name "*.app" -print -quit)
if [ -n "$app_bundle" ]; then
echo "Found app bundle: $app_bundle"
echo "Verifying app bundle signing..."
codesign --verify --deep --strict --verbose=2 "$app_bundle"
echo "Displaying app bundle signing info..."
codesign --display --verbose=2 "$app_bundle"
echo "Checking entitlements..."
codesign --display --entitlements :- "$app_bundle"
echo "Checking notarization status..."
xcrun stapler validate "$app_bundle" || echo "Warning: App bundle not notarized yet"
else
echo "No .app bundle found to verify"
fi
# Then check DMG if it exists
echo "Looking for DMG..."
dmg_file=$(find ./apps/desktop/dist -name "*.dmg" -print -quit)
if [ -n "$dmg_file" ]; then
echo "Found DMG: $dmg_file"
echo "Verifying DMG signing..."
codesign --verify --deep --strict --verbose=2 "$dmg_file"
echo "Displaying DMG signing info..."
codesign --display --verbose=2 "$dmg_file"
echo "Checking DMG notarization..."
xcrun stapler validate "$dmg_file" || echo "Warning: DMG not notarized yet"
else
echo "No DMG found to verify"
fi
# Finally check ZIP if it exists
echo "Looking for ZIP..."
zip_file=$(find ./apps/desktop/dist -name "*.zip" -print -quit)
if [ -n "$zip_file" ]; then
echo "Found ZIP: $zip_file"
echo "Note: ZIP files are not code signed, but their contents should be"
fi

33
.github/actions/build-server/action.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
inputs:
os:
description: "One of the supported platforms: windows"
required: true
arch:
description: "The architecture to build for: x64, arm64"
required: true
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: Run Linux server build
env:
MATRIX_ARCH: ${{ inputs.arch }}
shell: bash
run: |
pnpm run chore:update-build-info
pnpm nx --project=server package
- name: Prepare artifacts
shell: bash
run: |
mkdir -p upload
file=$(find ./apps/server/out -name '*.tar.xz' -print -quit)
name=${{ github.ref_name }}
cp "$file" "upload/TriliumNextNotes-Server-${name//\//-}-${{ inputs.os }}-${{ inputs.arch }}.tar.xz"

79
.github/actions/report-size/action.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: 'Bundle size reporter'
description: 'Post bundle size difference compared to another branch'
inputs:
branch:
description: 'Branch to compare to'
required: true
default: 'main'
paths:
description:
'Paths to json file bundle size report or folder containing bundles'
required: true
default: '/'
onlyDiff:
description: 'Report only different sizes'
required: false
default: 'false'
filter:
description: 'Regex filter based on file path'
required: false
unit:
description: 'Size unit'
required: false
default: 'KB'
# Comment inputs
comment:
description: 'Post comment'
required: false
default: 'true'
header:
description: 'Comment header'
required: false
default: 'Bundle size report'
append:
description: 'Append comment'
required: false
default: 'false'
ghToken:
description: 'Github token'
required: false
runs:
using: 'composite'
steps:
# Checkout branch to compare to [required]
- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}
path: br-base
token: ${{ inputs.ghToken }}
# Generate the bundle size difference report [required]
- name: Generate report
id: bundleSize
uses: nejcm/bundle-size-reporter-action@v1.4.1
with:
paths: ${{ inputs.paths }}
onlyDiff: ${{ inputs.onlyDiff }}
filter: ${{ inputs.filter }}
unit: ${{ inputs.unit }}
# Post github action summary
- name: Post summary
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
run: |
echo '${{ steps.bundleSize.outputs.summary }}' >> $GITHUB_STEP_SUMMARY
shell: bash
# Post github action comment
- name: Post comment
uses: marocchino/sticky-pull-request-comment@v2
if: ${{ steps.bundleSize.outputs.hasDifferences == 'true' }} # post only in case of changes
with:
number: ${{ github.event.pull_request.number }}
header: ${{ inputs.header }}
append: ${{ inputs.append }}
message: '${{ steps.bundleSize.outputs.summary }}'
GITHUB_TOKEN: ${{ inputs.ghToken }}

View File

@@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '37 4 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

100
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "develop" ]
schedule:
- cron: '20 7 * * 0'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# 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@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"

150
.github/workflows/dev.yml vendored Normal file
View File

@@ -0,0 +1,150 @@
name: Dev
on:
push:
branches: [ develop ]
pull_request:
branches: [ develop ]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: ${{ github.repository_owner }}/notes
TEST_TAG: ${{ github.repository_owner }}/notes:test
permissions:
pull-requests: write # for PR comments
jobs:
check-affected:
name: Check affected jobs (NX)
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # needed for https://github.com/marketplace/actions/nx-set-shas
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Check affected
run: pnpm nx affected --verbose -t typecheck build rebuild-deps test-build
test_dev:
name: Test development
runs-on: ubuntu-latest
needs:
- check-affected
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- name: Run the unit tests
run: pnpm run test:all
build_docker:
name: Build Docker image
runs-on: ubuntu-latest
needs:
- test_dev
- check-affected
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger client build
run: pnpm nx run client:build
- name: Send client bundle stats to RelativeCI
uses: relative-ci/agent-action@v3
with:
webpackStatsFile: ./apps/client/dist/webpack-stats.json
key: ${{ secrets.RELATIVE_CI_CLIENT_KEY }}
- name: Trigger server build
run: pnpm nx run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: apps/server
cache-from: type=gha
cache-to: type=gha,mode=max
test_docker:
name: Check Docker build
runs-on: ubuntu-latest
needs:
- build_docker
- check-affected
strategy:
matrix:
include:
- dockerfile: Dockerfile.alpine
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Update build info
run: pnpm run chore:update-build-info
- name: Trigger build
run: pnpm nx run server:build
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export to Docker
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
load: true
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Validate container run output
run: |
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --name trilium_local ${{ env.TEST_TAG }})
echo "Container ID: $CONTAINER_ID"
- name: Wait for the healthchecks to pass
uses: stringbean/docker-healthcheck-action@v3
with:
container: trilium_local
wait-time: 50
require-status: running
require-healthy: true
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
- name: Print entire log
if: always()
run: journalctl -u docker CONTAINER_NAME=trilium_local --no-pager

View File

@@ -1,53 +0,0 @@
name: Publish Docker image
on:
push:
tags: [v*]
jobs:
push_to_registries:
name: Push Docker image to multiple registries
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: |
zadam/trilium
ghcr.io/zadam/trilium
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}-latest
type=match,pattern=(\d+.\d+).\d+\-beta,enable=${{ endsWith(github.ref, 'beta') }},group=1,suffix=-latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to GitHub Docker Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create server-package.json
run: cat package.json | grep -v electron > server-package.json
- name: Build and Push
uses: docker/build-push-action@v2.7.0
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
push: true
cache-from: type=registry,ref=zadam/trilium:buildcache
cache-to: type=registry,ref=zadam/trilium:buildcache,mode=max
tags: ${{ steps.meta.outputs.tags }}

307
.github/workflows/main-docker.yml vendored Normal file
View File

@@ -0,0 +1,307 @@
on:
push:
branches:
- "develop"
- "feature/update**"
- "feature/server_esm**"
paths-ignore:
- "docs/**"
- "bin/**"
tags:
- "v*"
workflow_dispatch:
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: ${{ github.repository_owner }}/notes
TEST_TAG: ${{ github.repository_owner }}/notes:test
permissions:
contents: read
packages: write
jobs:
test_docker:
name: Check Docker build
runs-on: ubuntu-latest
strategy:
matrix:
include:
- dockerfile: Dockerfile.alpine
- dockerfile: Dockerfile
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install npm dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run the TypeScript build
run: pnpm run server:build
- name: Build and export to Docker
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
load: true
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Validate container run output
run: |
CONTAINER_ID=$(docker run -d --log-driver=journald --rm --network=host -e TRILIUM_PORT=8082 --volume ./apps/server/spec/db:/home/node/trilium-data --name trilium_local ${{ env.TEST_TAG }})
echo "Container ID: $CONTAINER_ID"
- name: Wait for the healthchecks to pass
uses: stringbean/docker-healthcheck-action@v3
with:
container: trilium_local
wait-time: 50
require-status: running
require-healthy: true
- name: Run Playwright tests
run: TRILIUM_DOCKER=1 TRILIUM_PORT=8082 pnpm exec nx run server-e2e:e2e
- name: Upload Playwright trace
if: failure()
uses: actions/upload-artifact@v4
with:
name: Playwright trace (${{ matrix.dockerfile }})
path: test-output/playwright/output
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: Playwright report (${{ matrix.dockerfile }})
path: playwright-report/
retention-days: 30
# Print the entire log of the container thus far, regardless if the healthcheck failed or succeeded
- name: Print entire log
if: always()
run: |
journalctl -u docker CONTAINER_NAME=trilium_local --no-pager
build:
name: Build Docker images
strategy:
fail-fast: false
matrix:
include:
- dockerfile: Dockerfile.alpine
platform: linux/amd64
image: ubuntu-latest
- dockerfile: Dockerfile
platform: linux/arm64
image: ubuntu-24.04-arm
- dockerfile: Dockerfile
platform: linux/arm/v7
image: ubuntu-24.04-arm
- dockerfile: Dockerfile
platform: linux/arm/v8
image: ubuntu-24.04-arm
runs-on: ${{ matrix.image }}
needs:
- test_docker
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run the TypeScript build
run: pnpm run server:build
- name: Update build info
run: pnpm run chore:update-build-info
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha
flavor: |
latest=false
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifest lists
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set IMAGE_NAME to lowercase
run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Set TEST_TAG to lowercase
run: echo "TEST_TAG=${TEST_TAG,,}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
# Extract the branch or tag name from the ref
REF_NAME=$(echo "${GITHUB_REF}" | sed 's/refs\/heads\///' | sed 's/refs\/tags\///')
# Create and push the manifest list with both the branch/tag name and the commit SHA
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${REF_NAME} \
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
# If the ref is a tag, also tag the image as stable as this is part of a 'release'
# and only go in the `if` if there is NOT a `-` in the tag's name, due to tagging of `-alpha`, `-beta`, etc...
if [[ "${GITHUB_REF}" == refs/tags/* && ! "${REF_NAME}" =~ - ]]; then
# First create stable tags
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
$(printf '${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable \
$(printf '${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
# Small delay to ensure stable tag is fully propagated
sleep 5
# Now update latest tags
docker buildx imagetools create \
-t ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
docker buildx imagetools create \
-t ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:stable
fi
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}

129
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Nightly Release
on:
# This can be used to automatically publish nightlies at UTC nighttime
schedule:
- cron: "0 2 * * *" # run at 2 AM UTC
# This can be used to allow manually triggering nightlies from the web interface
workflow_dispatch:
push:
branches:
- renovate/electron-forge*
pull_request:
paths:
- .github/actions/build-electron/*
- .github/workflows/nightly.yml
- forge.config.ts
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GITHUB_UPLOAD_URL: https://uploads.github.com/repos/TriliumNext/Notes/releases/179589950/assets{?name,label}
GITHUB_RELEASE_ID: 179589950
permissions:
contents: write
jobs:
nightly-electron:
name: Deploy nightly
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
os:
- name: macos
image: macos-latest
shell: bash
forge_platform: darwin
- name: linux
image: ubuntu-22.04
shell: bash
forge_platform: linux
- name: windows
image: win-signing
shell: cmd
forge_platform: win32
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Update nightly version
run: npm run chore:ci-update-nightly-version
- name: Run the build
uses: ./.github/actions/build-electron
with:
os: ${{ matrix.os.name }}
arch: ${{ matrix.arch }}
shell: ${{ matrix.os.shell }}
forge_platform: ${{ matrix.os.forge_platform }}
env:
APPLE_APP_CERTIFICATE_BASE64: ${{ secrets.APPLE_APP_CERTIFICATE_BASE64 }}
APPLE_APP_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_CERTIFICATE_PASSWORD }}
APPLE_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
prerelease: true
draft: false
fail_on_unmatched_files: true
files: apps/desktop/upload/*.*
tag_name: nightly
name: Nightly Build
- name: Publish artifacts
uses: actions/upload-artifact@v4
if: ${{ github.event_name == 'pull_request' }}
with:
name: TriliumNextNotes ${{ matrix.os.name }} ${{ matrix.arch }}
path: apps/desktop/upload
nightly-server:
name: Deploy server nightly
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-22.04
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
- name: Run the build
uses: ./.github/actions/build-server
with:
os: linux
arch: ${{ matrix.arch }}
- name: Publish release
uses: softprops/action-gh-release@v2.3.2
if: ${{ github.event_name != 'pull_request' }}
with:
make_latest: false
prerelease: true
draft: false
fail_on_unmatched_files: true
files: upload/*.*
tag_name: nightly
name: Nightly Build

43
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: playwright
on:
push:
branches:
- master
pull_request:
permissions:
actions: read
contents: read
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
filter: tree:0
fetch-depth: 0
# This enables task distribution via Nx Cloud
# Run this command as early as possible, before dependencies are installed
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
# - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- uses: nrwl/nx-set-shas@v4
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
# - run: npx nx-cloud record -- echo Hello World
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
# When you enable task distribution, run the e2e-ci task instead of e2e
- run: pnpm exec nx affected -t e2e

20
.github/workflows/release-winget.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Release to winget
on:
release:
types: [ published ]
workflow_dispatch:
inputs:
release_tag:
description: 'Git tag to release from'
type: string
required: true
jobs:
release-winget:
runs-on: ubuntu-latest
steps:
- name: Publish to WinGet
uses: vedantmgoyal9/winget-releaser@main
with:
identifier: TriliumNext.Notes
token: ${{ secrets.WINGET_PAT }}
release-tag: ${{ github.event.inputs.release_tag || github.event.release.tag_name }}

126
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,126 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
discussions: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
make-electron:
name: Make Electron
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
os:
- name: macos
image: macos-latest
shell: bash
forge_platform: darwin
- name: linux
image: ubuntu-latest
shell: bash
forge_platform: linux
- name: windows
image: win-signing
shell: cmd
forge_platform: win32
runs-on: ${{ matrix.os.image }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Set up node & dependencies
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- uses: nrwl/nx-set-shas@v4
- name: Run the build
uses: ./.github/actions/build-electron
with:
os: ${{ matrix.os.name }}
arch: ${{ matrix.arch }}
shell: ${{ matrix.os.shell }}
forge_platform: ${{ matrix.os.forge_platform }}
env:
APPLE_APP_CERTIFICATE_BASE64: ${{ secrets.APPLE_APP_CERTIFICATE_BASE64 }}
APPLE_APP_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_APP_CERTIFICATE_PASSWORD }}
APPLE_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
WINDOWS_SIGN_EXECUTABLE: ${{ vars.WINDOWS_SIGN_EXECUTABLE }}
- name: Upload the artifact
uses: actions/upload-artifact@v4
with:
name: release-desktop-${{ matrix.os.name }}-${{ matrix.arch }}
path: apps/desktop/upload/*.*
build_server:
name: Build Linux Server
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
include:
- arch: x64
runs-on: ubuntu-22.04
- arch: arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v4
- name: Run the build
uses: ./.github/actions/build-server
with:
os: linux
arch: ${{ matrix.arch }}
- name: Upload the artifact
uses: actions/upload-artifact@v4
with:
name: release-server-linux-${{ matrix.arch }}
path: upload/*.*
publish_release:
name: Publish release
runs-on: ubuntu-latest
needs:
- make-electron
- build_server
steps:
- run: mkdir upload
- uses: actions/checkout@v4
with:
sparse-checkout: |
docs/Release Notes
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
pattern: release-*
path: upload
- name: Publish stable release
uses: softprops/action-gh-release@v2.3.2
with:
draft: false
body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md
fail_on_unmatched_files: true
files: upload/*.*
discussion_category_name: Announcements
make_latest: ${{ !contains(github.ref, 'rc') }}
prerelease: ${{ contains(github.ref, 'rc') }}
token: ${{ secrets.RELEASE_PAT }}

61
.gitignore vendored
View File

@@ -1,16 +1,49 @@
.DS_Store # See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
node_modules/
dist/ # compiled output
src/public/app-dist/ dist
tmp
out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
*.db testem.log
config.ini /typings
cert.key
cert.crt # System Files
server-package.json .DS_Store
.idea/httpRequests/ Thumbs.db
data/
data-test/ .nx/cache
tmp/ .nx/workspace-data
.eslintcache
vite.config.*.timestamp*
vitest.config.*.timestamp*
test-output
apps/*/data
apps/*/out
upload
.rollup.cache
*.tsbuildinfo
/result
.svelte-kit

View File

@@ -1,15 +0,0 @@
FROM gitpod/workspace-full
RUN sudo apt-get update \
&& sudo apt-get install -yq --no-install-recommends \
libpng16-16 \
libpng-dev \
pkg-config \
autoconf \
libtool \
build-essential \
nasm \
libx11-dev \
libxkbfile-dev \
&& sudo rm -rf /var/lib/apt/lists/*

View File

@@ -1,11 +0,0 @@
image:
file: .gitpod.dockerfile
tasks:
- before: nvm install 18.18.2 && nvm use 18.18.2
init: npm install
command: npm run start-server
ports:
- port: 8080
onOpen: open-preview

View File

@@ -6,8 +6,10 @@
<option name="TAB_SIZE" value="2" /> <option name="TAB_SIZE" value="2" />
</value> </value>
</option> </option>
<JSCodeStyleSettings version="0"> <codeStyleSettings language="JSON">
<option name="USE_EXPLICIT_JS_EXTENSION" value="TRUE" /> <indentOptions>
</JSCodeStyleSettings> <option name="INDENT_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

2
.mailmap Normal file
View File

@@ -0,0 +1,2 @@
Adam Zivner <adam.zivner@gmail.com>
Adam Zivner <zadam.apps@gmail.com>

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
save-prefix = ''

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.16.0

2
.nxignore Normal file
View File

@@ -0,0 +1,2 @@
_regroup
_regroup_monorepo

16
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"editorconfig.editorconfig",
"lokalise.i18n-ally",
"ms-azuretools.vscode-docker",
"ms-playwright.playwright",
"nrwl.angular-console",
"redhat.vscode-yaml",
"tobermory.es6-string-html",
"vitest.explorer",
"yzhang.markdown-all-in-one",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}

33
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# An array of strings which contain Language Ids defined by VS Code
# You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
languageIds:
- javascript
- typescript
- html
# An array of RegExes to find the key usage. **The key should be captured in the first match group**.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
usageMatchRegex:
# The following example shows how to detect `t("your.i18n.keys")`
# the `{key}` will be placed by a proper keypath matching regex,
# you can ignore it and use your own matching rules as well
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
# A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
# and works like how the i18next framework identifies the namespace scope from the
# useTranslation() hook.
# You should unescape RegEx strings in order to fit in the YAML file
# To help with this, you can use https://www.freeformatter.com/json-escape.html
scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
# An array of strings containing refactor templates.
# The "$1" will be replaced by the keypath specified.
refactorTemplates:
- t("$1")
- ${t("$1")}
- <%= t("$1") %>
# If set to true, only enables this custom framework (will disable all built-in frameworks)
monopoly: true

24
.vscode/launch.json vendored
View File

@@ -1,24 +0,0 @@
{
"version": "0.2.0",
"configurations": [
// nodemon should be installed globally, use npm i -g nodemon
{
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"name": "nodemon start-server",
"program": "${workspaceFolder}/src/www",
"request": "launch",
"restart": true,
"runtimeExecutable": "nodemon",
"env": {
"TRILIUM_ENV": "dev",
"TRILIUM_DATA_DIR": "./data"
},
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"outputCapture": "std",
},
]
}

34
.vscode/settings.json vendored
View File

@@ -1,4 +1,32 @@
{ {
"editor.formatOnSave": true, "editor.formatOnSave": false,
"files.eol": "\n" "files.eol": "\n",
} "i18n-ally.sourceLanguage": "en",
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": [
"apps/server/src/assets/translations",
"apps/client/src/translations"
],
"npm.exclude": [
"**/dist",
],
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[css]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"github-actions.workflows.pinned.workflows": [
".github/workflows/nightly.yml"
],
"typescript.validate.enable": true,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

24
.vscode/snippets.code-snippets vendored Normal file
View File

@@ -0,0 +1,24 @@
{
// Place your Notes workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"JQuery HTMLElement field": {
"scope": "typescript",
"prefix": "jqf",
"body": ["private $${1:name}!: JQuery<HTMLElement>;"]
}
}

View File

@@ -1,42 +0,0 @@
# !!! Don't try to build this Dockerfile directly, run it through bin/build-docker.sh script !!!
FROM node:18.18.2-alpine
# Create app directory
WORKDIR /usr/src/app
# Bundle app source
COPY . .
COPY server-package.json package.json
# Install app dependencies
RUN set -x \
&& apk add --no-cache --virtual .build-dependencies \
autoconf \
automake \
g++ \
gcc \
libtool \
make \
nasm \
libpng-dev \
python3 \
&& npm install \
&& apk del .build-dependencies \
&& npm run webpack \
&& npm prune --omit=dev \
&& cp src/public/app/share.js src/public/app-dist/. \
&& cp -r src/public/app/doc_notes src/public/app-dist/. \
&& rm -rf src/public/app
# Some setup tools need to be kept
RUN apk add --no-cache su-exec shadow
# Add application user and setup proper volume permissions
RUN adduser -s /bin/false node; exit 0
# Start the application
EXPOSE 8080
CMD [ "./start-docker.sh" ]
HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js

View File

@@ -1,82 +0,0 @@
# Trilium Notes
[English](https://github.com/zadam/trilium/blob/master/README.md) | [Chinese](https://github.com/zadam/trilium/blob/master/README-ZH_CN.md) | [Russian](https://github.com/zadam/trilium/blob/master/README.ru.md) | [Japanese](https://github.com/zadam/trilium/blob/master/README.ja.md) | [Italian](https://github.com/zadam/trilium/blob/master/README.it.md)
[![Join the chat at https://gitter.im/trilium-notes/Lobby](https://badges.gitter.im/trilium-notes/Lobby.svg)](https://gitter.im/trilium-notes/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Trilium Notes 是一个层次化的笔记应用程序,专注于建立大型个人知识库。请参阅[屏幕截图](https://github.com/zadam/trilium/wiki/Screenshot-tour)以快速了解:
![](https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png)
Ukraine is currently suffering from Russian aggression, please consider donating to [one of these charities](https://old.reddit.com/r/ukraine/comments/s6g5un/want_to_support_ukraine_heres_a_list_of_charities/).
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="600"/>
<img src="https://signmyrocket.com//uploads/2b2a523cd0c0e76cdbba95a89a9636b2_1676971281.jpg" alt="Trilium Notes supports Ukraine!" width="600"/>
## 特性
* 笔记可以排列成任意深的树。单个笔记可以放在树中的多个位置(请参阅[克隆](https://github.com/zadam/trilium/wiki/Cloning-notes)
* 丰富的所见即所得笔记编辑功能,包括带有 Markdown [自动格式化功能的](https://github.com/zadam/trilium/wiki/Text-notes#autoformat)表格,图像和[数学](https://github.com/zadam/trilium/wiki/Text-notes#math-support)
* 支持编辑[使用源代码的笔记](https://github.com/zadam/trilium/wiki/Code-notes),包括语法高亮显示
* 笔记之间快速[导航](https://github.com/zadam/trilium/wiki/Note-navigation),全文搜索和[笔记聚焦](https://github.com/zadam/trilium/wiki/Note-hoisting)
* 无缝[笔记版本控制](https://github.com/zadam/trilium/wiki/Note-revisions)
* 笔记[属性](https://github.com/zadam/trilium/wiki/Attributes)可用于笔记组织,查询和高级[脚本编写](https://github.com/zadam/trilium/wiki/Scripts)
* [同步](https://github.com/zadam/trilium/wiki/Synchronization)与自托管同步服务器
* 有一个[第三方提供的同步服务器托管服务](https://trilium.cc/paid-hosting)
* 公开地[分享](https://github.com/zadam/trilium/wiki/Sharing)(发布)笔记到互联网
* 具有按笔记粒度的强大的[笔记加密](https://github.com/zadam/trilium/wiki/Protected-notes)
* 使用自带的 Excalidraw 来绘制图表(笔记类型“画布”)
* [关系图](https://github.com/zadam/trilium/wiki/Relation-map)和[链接图](https://github.com/zadam/trilium/wiki/Link-map),用于可视化笔记及其关系
* [脚本](https://github.com/zadam/trilium/wiki/Scripts) - 请参阅[高级功能展示](https://github.com/zadam/trilium/wiki/Advanced-showcases)
* 在拥有超过 10 万条笔记时仍能保持良好的可用性和性能
* 针对智能手机和平板电脑进行优化的[用于移动设备的前端](https://github.com/zadam/trilium/wiki/Mobile-frontend)
* [夜间主题](https://github.com/zadam/trilium/wiki/Themes)
* [Evernote](https://github.com/zadam/trilium/wiki/Evernote-import) 和 [Markdown 导入导出](https://github.com/zadam/trilium/wiki/Markdown)功能
* 使用[网页剪藏](https://github.com/zadam/trilium/wiki/Web-clipper)轻松保存互联网上的内容
## 构建
Trilium 可以用作桌面应用程序Linux 和 Windows或服务器Linux上托管的 Web 应用程序。虽然有 macOS 版本的桌面应用程序,但它[不受支持](https://github.com/zadam/trilium/wiki/FAQ#mac-os-support)。
* 如果要在桌面上使用 Trilium请从[最新版本](https://github.com/zadam/trilium/releases/latest)下载适用于您平台的二进制版本,解压缩该软件包并运行`trilium`可执行文件。
* 如果要在服务器上安装 Trilium请参考[此页面](https://github.com/zadam/trilium/wiki/Server-installation)。
* 当前仅支持(测试过)最近发布的 Chrome 和 Firefox 浏览器。
Trilium 也提供 Flatpak
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium)
## 文档
[有关文档页面的完整列表,请参见 Wiki。](https://github.com/zadam/trilium/wiki/)
* [Wiki 的中文翻译版本](https://github.com/baddate/trilium/wiki/)
您还可以阅读[个人知识库模式](https://github.com/zadam/trilium/wiki/Patterns-of-personal-knowledge-base),以获取有关如何使用 Trilium 的灵感。
## 贡献
使用基于浏览器的开发环境
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/zadam/trilium)
或者克隆本仓库到本地,并运行
```
npm install
npm run start-server
```
## 致谢
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 市面上最好的所见即所得编辑器,拥有互动性强且聆听能力强的团队
* [FancyTree](https://github.com/mar10/fancytree) - 一个非常丰富的关于树的库强大到没有对手。没有它Trilium Notes 将不会如此。
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 支持大量语言的代码编辑器
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 强大的可视化连接库。用于[关系图](https://github.com/zadam/trilium/wiki/Relation-map)和[链接图](https://github.com/zadam/trilium/wiki/Link-map)
## 捐赠
你可以通过 GitHub Sponsors[PayPal](https://paypal.me/za4am) 或者比特币 (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2) 来捐赠。
## 许可证
本程序是自由软件:你可以再发布本软件和/或修改本软件,只要你遵循 Free Software Foundation 发布的 GNU Affero General Public License 的第三版或者任何(由你选择)更晚的版本。

View File

@@ -1,93 +0,0 @@
# Trilium Notes
## Trilium è in manutenzione - vedi i dettagli in https://github.com/zadam/trilium/issues/4620
Le discussioni preliminari sull'organizzazione si stanno svolgendo in [Trilium Next discussions](https://github.com/orgs/TriliumNext/discussions).
[![Join the chat at https://gitter.im/trilium-notes/Lobby](https://badges.gitter.im/trilium-notes/Lobby.svg)](https://gitter.im/trilium-notes/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [English](https://github.com/zadam/trilium/blob/master/README.md) | [Chinese](https://github.com/zadam/trilium/blob/master/README-ZH_CN.md) | [Russian](https://github.com/zadam/trilium/blob/master/README.ru.md) | [Japanese](https://github.com/zadam/trilium/blob/master/README.ja.md) | [Italian](https://github.com/zadam/trilium/blob/master/README.it.md)
Trilium Notes è un'applicazione per appunti ad organizzazione gerarchica, studiata per la costruzione di archivi di conoscenza personali di grandi dimensioni.
Vedi [fotografie](https://github.com/zadam/trilium/wiki/Screenshot-tour) per una panoramica veloce:
<a href="https://github.com/zadam/trilium/wiki/Screenshot-tour"><img src="https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png" alt="Trilium Screenshot" width="1000"></a>
L'Ucraina si sta difendendo dall'aggressione russa, considera [donare all'esercito ucraino o a organizzazioni umanitarie](https://standforukraine.com/).
<p float="left">
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="400"/>
<img src="https://signmyrocket.com//uploads/2b2a523cd0c0e76cdbba95a89a9636b2_1676971281.jpg" alt="Trilium Notes supports Ukraine!" width="570"/>
</p>
## 🎁 Funzionalità
* Gli appunti possono essere organizzati in un albero di profondità arbitraria. Un singolo appunto può essere collocato in più posti nell'albero (vedi [clonazione](https://github.com/zadam/trilium/wiki/Cloning-notes))
* Ricco editor visuale (WYSIWYG), con supporto -tra l'altro- per tabelle, immagini ed [espressioni matematiche](https://github.com/zadam/trilium/wiki/Text-notes#math-support) e con [formattazione automatica](https://github.com/zadam/trilium/wiki/Text-notes#autoformat) per markdown
* Supporto per la modifica di [appunti con codice sorgente](https://github.com/zadam/trilium/wiki/Code-notes), con evidenziazione della sintassi
* [Navigazione veloce](https://github.com/zadam/trilium/wiki/Note-navigation) tra gli appunti, ricerca testuale completa e [fissaggio degli appunti](https://github.com/zadam/trilium/wiki/Note-hoisting)
* Supporto integrato ed automatico per le [revisioni degli appunti](https://github.com/zadam/trilium/wiki/Note-revisions)
* Gli [attributi](https://github.com/zadam/trilium/wiki/Attributes) degli appunti possono essere utilizzati per l'organizzazione, per l'interrogazione e per lo scripting avanzato (prorgrammazione).
* [Sincronizzazione](https://github.com/zadam/trilium/wiki/Synchronization) con un server di sincronizzazione auto-ospitato
* c'è un [servizio di terze parti per ospitare server di sincronizzazione](https://trilium.cc/paid-hosting)
* [Condivisione](https://github.com/zadam/trilium/wiki/Sharing) (pubblicazione) di appunti sull'internet pubblico
* Robusta [crittografia](https://github.com/zadam/trilium/wiki/Protected-notes) configurabile singolarmente per ogni appunto
* Disegno di diagrammi con Excalidraw (tipo di appunto "canvas")
* [Mappe relazionali](https://github.com/zadam/trilium/wiki/Relation-map) e [mappe di collegamenti](https://github.com/zadam/trilium/wiki/Link-map) per visualizzare gli appunti e le loro relazioni
* [Scripting](https://github.com/zadam/trilium/wiki/Scripts) - vedi [Esempi avanzati](https://github.com/zadam/trilium/wiki/Advanced-showcases)
* [API REST](https://github.com/zadam/trilium/wiki/ETAPI) per l'automazione
* Si adatta bene sia in termini di usabilità che di prestazioni fino ad oltre 100 000 appunti
* Interfaccia utente ottimizzata per il [mobile](https://github.com/zadam/trilium/wiki/Mobile-frontend) (smartphone e tablet)
* [Tema Notturno](https://github.com/zadam/trilium/wiki/Themes)
* Supporto per importazione ed esportazione da e per [Evernote](https://github.com/zadam/trilium/wiki/Evernote-import) e [Markdown import](https://github.com/zadam/trilium/wiki/Markdown)
* [Web Clipper](https://github.com/zadam/trilium/wiki/Web-clipper) per il salvataggio facile di contenuti web
Dai un'occhiata a [awesome-trilium](https://github.com/Nriver/awesome-trilium) per temi, script, plugin e altro di terze parti.
## 🏗 Rilasci
Trilium è fornito come applicazione desktop (Linux e Windows) o come applicazione web ospitata sul tuo server (Linux). La versione desktop per Mac OS è disponibile, ma [non è supportata](https://github.com/zadam/trilium/wiki/FAQ#mac-os-support).
* Se vuoi usare Trilium sul tuo desktop, scarica il rilascio binario per la tua piattaforma dall'[ultimo rilascio](https://github.com/zadam/trilium/releases/latest), decomprimi l'archivio e avvia l'eseguibile ```trilium```.
* Se vuoi installare Trilium su un server, segui [questa pagina](https://github.com/zadam/trilium/wiki/Server-installation).
* Per ora solo Chrome e Firefox sono i browser supportati (testati).
Trilium è anche disponibile su Flatpak:
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium)
## 📝 Documentazione
[Vedi la wiki per una lista completa delle pagine di documentazione.](https://github.com/zadam/trilium/wiki/)
Puoi anche leggere ["Patterns of personal knowledge base"](https://github.com/zadam/trilium/wiki/Patterns-of-personal-knowledge-base) per avere un'ispirazione su come potresti utilizzare Trilium.
## 💻 Contribuire
Usa un ambiente di sviluppo basato su browser
[![Apri in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/zadam/trilium)
O clona localmente ed esegui
```
npm install
npm run start-server
```
## 📢 Riconoscimenti
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - miglior editor visuale (WYSIWYG) sul mercato, squadra di sviluppo attenta e reattiva
* [FancyTree](https://github.com/mar10/fancytree) - libreria per alberi molto ricca di funzionalità, senza pari. Trilium Notes non sarebbe lo stesso senza di essa.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - editor di codice con supporto per un'enorme quantità di linguaggi.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - libreria per la connettività visuale senza pari. Utilizzata per [mappe relazionali](https://github.com/zadam/trilium/wiki/Relation-map) e [mappe di collegamenti](https://github.com/zadam/trilium/wiki/Link-map).
## 🤝 Supporto
È possibile supportare Trilium attraverso Github Sponsors, [PayPal](https://paypal.me/za4am) o Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
## 🔑 Licenza
Questo programma è software libero: è possibile redistribuirlo e/o modificarlo nei termini della GNU Affero General Public License come pubblicata dalla Free Software Foundation, sia la versione 3 della Licenza, o (a propria scelta) qualsiasi versione successiva.

View File

@@ -1,83 +0,0 @@
# Trilium Notes
[English](https://github.com/zadam/trilium/blob/master/README.md) | [Chinese](https://github.com/zadam/trilium/blob/master/README-ZH_CN.md) | [Russian](https://github.com/zadam/trilium/blob/master/README.ru.md) | [Japanese](https://github.com/zadam/trilium/blob/master/README.ja.md) | [Italian](https://github.com/zadam/trilium/blob/master/README.it.md)
Trilium Notes は、大規模な個人知識ベースの構築に焦点を当てた、階層型ノートアプリケーションです。概要は[スクリーンショット](https://github.com/zadam/trilium/wiki/Screenshot-tour)をご覧ください:
<a href="https://github.com/zadam/trilium/wiki/Screenshot-tour"><img src="https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png" alt="Trilium Screenshot" width="1000"></a>
ウクライナは現在、ロシアの侵略から自国を守っています。[ウクライナ軍や人道的な慈善団体への寄付](https://standforukraine.com/)をご検討ください。
<p float="left">
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="400"/>
<img src="https://signmyrocket.com//uploads/2b2a523cd0c0e76cdbba95a89a9636b2_1676971281.jpg" alt="Trilium Notes supports Ukraine!" width="570"/>
</p>
## 🎁 特徴
* ノートは、任意の深さのツリーに配置できます。単一のノートをツリー内の複数の場所に配置できます ([cloning](https://github.com/zadam/trilium/wiki/Cloning-notes) を参照)
* マークダウン[オートフォーマット](https://github.com/zadam/trilium/wiki/Text-notes#autoformat)による、表、画像、[数学](https://github.com/zadam/trilium/wiki/Text-notes#math-support)などの豊富な WYSIWYG ノート編集機能
* シンタックスハイライトを含む[ソースコード付きノート](https://github.com/zadam/trilium/wiki/Code-notes)の編集をサポート
* [ノート間のナビゲーション](https://github.com/zadam/trilium/wiki/Note-navigation)、全文検索、[ノートホイスト](https://github.com/zadam/trilium/wiki/Note-hoisting)が高速かつ簡単に行えます
* シームレスな[ノートのバージョン管理](https://github.com/zadam/trilium/wiki/Note-revisions)
* ノート[属性](https://github.com/zadam/trilium/wiki/Attributes)は、ノート整理、クエリ、高度な[スクリプト](https://github.com/zadam/trilium/wiki/Scripts)に使用できます
* 自己ホスト型同期サーバーとの[同期](https://github.com/zadam/trilium/wiki/Synchronization)
* [同期サーバーをホストするサードパーティ・サービス](https://trilium.cc/paid-hosting)があります
* 公開インターネットへのノートの[共有](https://github.com/zadam/trilium/wiki/Sharing)(公開)
* ノートごとの粒度を持つ強力な[ノート暗号化](https://github.com/zadam/trilium/wiki/Protected-notes)
* 組み込みの Excalidraw を使用した図のスケッチ (ノート タイプ"キャンバス")
* ノートとその関係を可視化するための[関係図](https://github.com/zadam/trilium/wiki/Relation-map)と[リンクマップ](https://github.com/zadam/trilium/wiki/Link-map)
* [スクリプティング](https://github.com/zadam/trilium/wiki/Scripts) - [高度なショーケース](https://github.com/zadam/trilium/wiki/Advanced-showcases)を参照
* 自動化のための [REST API](https://github.com/zadam/trilium/wiki/ETAPI)
* ユーザビリティとパフォーマンスの両方で 100 000 ノート以上に拡張可能
* スマートフォンとタブレット向けのタッチ最適化[モバイルフロントエンド](https://github.com/zadam/trilium/wiki/Mobile-frontend)
* [ナイトテーマ](https://github.com/zadam/trilium/wiki/Themes)
* [Evernote](https://github.com/zadam/trilium/wiki/Evernote-import) と [Markdown のインポートとエクスポート](https://github.com/zadam/trilium/wiki/Markdown)
* Web コンテンツを簡単に保存するための [Web クリッパー](https://github.com/zadam/trilium/wiki/Web-clipper)
サードパーティのテーマ、スクリプト、プラグインなどは、 [awesome-trilium](https://github.com/Nriver/awesome-trilium) をチェックしてください。
## 🏗 ビルド
Trilium は、デスクトップアプリケーションLinux、Windowsまたはサーバー上でホストされるウェブアプリケーションLinuxとして提供されます。 Mac OS のデスクトップビルドも利用可能ですが、 [unsupported](https://github.com/zadam/trilium/wiki/FAQ#mac-os-support) となっています。
* デスクトップで Trilium を使用したい場合は、 [latest release](https://github.com/zadam/trilium/releases/latest) からお使いのプラットフォームのバイナリリリースをダウンロードし、パッケージを解凍して ``trilium`` の実行ファイルを実行してください。
* サーバーに Trilium をインストールする場合は、[このページ](https://github.com/zadam/trilium/wiki/Server-installation)に従ってください。
* 現在、対応(動作確認)しているブラウザは、最近の Chrome と Firefox のみです。
Trilium は Flatpak としても提供されます:
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium)
## 📝 ドキュメント
[ドキュメントページの全リストはwikiをご覧ください。](https://github.com/zadam/trilium/wiki/)
また、[個人的な知識基盤のパターン](https://github.com/zadam/trilium/wiki/Patterns-of-personal-knowledge-base)を読むと、 Trilium の使い方のヒントを得ることができます。
## 💻 コントリビュート
ブラウザベースの開発環境を使用
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/zadam/trilium)
または、ローカルにクローンして実行
```
npm install
npm run start-server
```
## 📢 シャウトアウト
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 市場で最高の WYSIWYG エディター、非常にインタラクティブで聞き上手なチーム
* [FancyTree](https://github.com/mar10/fancytree) - 真の競争相手がいない、非常に機能豊富なツリーライブラリです。 Trilium Notes は、これなしでは成り立たないでしょう。
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 膨大な数の言語をサポートするコードエディタ
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 競合のないビジュアルコネクティビティライブラリです。[関係図](https://github.com/zadam/trilium/wiki/Relation-map)、[リンク図](https://github.com/zadam/trilium/wiki/Link-map)で使用。
## 🤝 サポート
GitHub スポンサー、[PayPal](https://paypal.me/za4am)もしくは Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2) にて Trilium をサポートすることができます。
## 🔑 ライセンス
このプログラムはフリーソフトウェアです:フリーソフトウェア財団が発行した GNU Affero General Public License のバージョン3、またはそれ以降のバージョンのいずれかに従って、再配布および/または改変することができます。

195
README.md
View File

@@ -1,93 +1,172 @@
> [!IMPORTANT] # TriliumNext Notes
> The TriliumNext team has received the original Trilium Notes repository (`zadam/trilium`). We will soon enter the process in which TriliumNext will become the original Trilium Notes again.
>
> For now, we invite you to have a look at the [TriliumNext](https://github.com/TriliumNext/Notes) repo. You should be able to migrate to TriliumNext without any problems from the original Trilium.
>
> Thanks to @zadam for his original work and for allowing us to continue using the Trilium Notes name once again.
# Trilium Notes ![GitHub Sponsors](https://img.shields.io/github/sponsors/eliandoran?style=flat-square)
![Docker Pulls](https://img.shields.io/docker/pulls/triliumnext/notes?style=flat-square)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/triliumnext/notes/total?style=flat-square)
[![RelativeCI](https://badges.relative-ci.com/badges/Di5q7dz9daNDZ9UXi0Bp?branch=develop&style=flat-square)](https://app.relative-ci.com/projects/Di5q7dz9daNDZ9UXi0Bp)
[![Join the chat at https://gitter.im/trilium-notes/Lobby](https://badges.gitter.im/trilium-notes/Lobby.svg)](https://gitter.im/trilium-notes/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [English](https://github.com/zadam/trilium/blob/master/README.md) | [Chinese](https://github.com/zadam/trilium/blob/master/README-ZH_CN.md) | [Russian](https://github.com/zadam/trilium/blob/master/README.ru.md) | [Japanese](https://github.com/zadam/trilium/blob/master/README.ja.md) | [Italian](https://github.com/zadam/trilium/blob/master/README.it.md) [English](./README.md) | [Chinese](./docs/README-ZH_CN.md) | [Russian](./docs/README.ru.md) | [Japanese](./docs/README.ja.md) | [Italian](./docs/README.it.md) | [Spanish](./docs/README.es.md)
>>>>>>> old/develop
TriliumNext Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases. See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for quick overview:
See [screenshots](https://github.com/zadam/trilium/wiki/Screenshot-tour) for quick overview: <a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="./docs/app.png" alt="Trilium Screenshot" width="1000"></a>
<a href="https://github.com/zadam/trilium/wiki/Screenshot-tour"><img src="https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png" alt="Trilium Screenshot" width="1000"></a>
Ukraine is currently defending itself from Russian aggression, please consider [donating to Ukrainian Army or humanitarian charities](https://standforukraine.com/).
<p float="left">
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="400"/>
<img src="https://signmyrocket.com//uploads/2b2a523cd0c0e76cdbba95a89a9636b2_1676971281.jpg" alt="Trilium Notes supports Ukraine!" width="570"/>
</p>
## 🎁 Features ## 🎁 Features
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://github.com/zadam/trilium/wiki/Cloning-notes)) * Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
* Rich WYSIWYG note editing including e.g. tables, images and [math](https://github.com/zadam/trilium/wiki/Text-notes#math-support) with markdown [autoformat](https://github.com/zadam/trilium/wiki/Text-notes#autoformat) * Rich WYSIWYG note editor including e.g. tables, images and [math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown [autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
* Support for editing [notes with source code](https://github.com/zadam/trilium/wiki/Code-notes), including syntax highlighting * Support for editing [notes with source code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax highlighting
* Fast and easy [navigation between notes](https://github.com/zadam/trilium/wiki/Note-navigation), full text search and [note hoisting](https://github.com/zadam/trilium/wiki/Note-hoisting) * Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
* Seamless [note versioning](https://github.com/zadam/trilium/wiki/Note-revisions) * Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
* Note [attributes](https://github.com/zadam/trilium/wiki/Attributes) can be used for note organization, querying and advanced [scripting](https://github.com/zadam/trilium/wiki/Scripts) * Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
* [Synchronization](https://github.com/zadam/trilium/wiki/Synchronization) with self-hosted sync server * UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
* Direct [OpenID and TOTP integration](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/Multi-Factor%20Authentication.md) for more secure login
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting) * there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
* [Sharing](https://github.com/zadam/trilium/wiki/Sharing) (publishing) notes to public internet * [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
* Strong [note encryption](https://github.com/zadam/trilium/wiki/Protected-notes) with per-note granularity * Strong [note encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with per-note granularity
* Sketching diagrams with built-in Excalidraw (note type "canvas") * Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
* [Relation maps](https://github.com/zadam/trilium/wiki/Relation-map) and [link maps](https://github.com/zadam/trilium/wiki/Link-map) for visualizing notes and their relations * [Relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map) and [link maps](https://triliumnext.github.io/Docs/Wiki/link-map) for visualizing notes and their relations
* [Scripting](https://github.com/zadam/trilium/wiki/Scripts) - see [Advanced showcases](https://github.com/zadam/trilium/wiki/Advanced-showcases) * Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
* [REST API](https://github.com/zadam/trilium/wiki/ETAPI) for automation * [Geo maps](./docs/User%20Guide/User%20Guide/Note%20Types/Geo%20Map.md) with location pins and GPX tracks
* [Scripting](https://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
* Scales well in both usability and performance upwards of 100 000 notes * Scales well in both usability and performance upwards of 100 000 notes
* Touch optimized [mobile frontend](https://github.com/zadam/trilium/wiki/Mobile-frontend) for smartphones and tablets * Touch optimized [mobile frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for smartphones and tablets
* [Night theme](https://github.com/zadam/trilium/wiki/Themes) * Built-in [dark theme](https://triliumnext.github.io/Docs/Wiki/themes), support for user themes
* [Evernote](https://github.com/zadam/trilium/wiki/Evernote-import) and [Markdown import & export](https://github.com/zadam/trilium/wiki/Markdown) * [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
* [Web Clipper](https://github.com/zadam/trilium/wiki/Web-clipper) for easy saving of web content * [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
* Customizable UI (sidebar buttons, user-defined widgets, ...)
* [Metrics](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics.md), along with a [Grafana Dashboard](./docs/User%20Guide/User%20Guide/Advanced%20Usage/Metrics/grafana-dashboard.json)
Check out [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more. Check out the following third-party resources/communities for more TriliumNext related goodies:
## 🏗 Builds - [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
Trilium is provided as either desktop application (Linux and Windows) or web application hosted on your server (Linux). Mac OS desktop build is available, but it is [unsupported](https://github.com/zadam/trilium/wiki/FAQ#mac-os-support). ## ⚠️ Why TriliumNext?
* If you want to use Trilium on the desktop, download binary release for your platform from [latest release](https://github.com/zadam/trilium/releases/latest), unzip the package and run ```trilium``` executable. [The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620).
* If you want to install Trilium on server, follow [this page](https://github.com/zadam/trilium/wiki/Server-installation).
* Currently only recent Chrome and Firefox are supported (tested) browsers.
Trilium is also provided as a Flatpak: ### Migrating from Trilium?
[<img width="240" src="https://flathub.org/assets/badges/flathub-badge-en.png">](https://flathub.org/apps/details/com.github.zadam.trilium) There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Simply [install TriliumNext/Notes](#-installation) as usual and it will use your existing database.
## 📝 Documentation Versions up to and including [v0.90.4](https://github.com/TriliumNext/Notes/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext have their sync versions incremented.
[See wiki for complete list of documentation pages.](https://github.com/zadam/trilium/wiki/) ## 📖 Documentation
We're currently in the progress of moving the documentation to in-app (hit the `F1` key within Trilium). As a result, there may be some missing parts until we've completed the migration. If you'd prefer to navigate through the documentation within GitHub, you can navigate the [User Guide](./docs/User%20Guide/User%20Guide/) documentation.
Below are some quick links for your convenience to navigate the documentation:
- [Server installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation.md)
- [Docker installation](./docs/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.md)
- [Upgrading TriliumNext](./docs/User%20Guide/User%20Guide/Installation%20%26%20Setup/Upgrading%20TriliumNext.md)
- [Concepts and Features - Note](./docs/User%20Guide/User%20Guide/Basic%20Concepts%20and%20Features/Notes.md)
- [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge)
Until we finish reorganizing the documentation, you may also want to [browse the old documentation](https://triliumnext.github.io/Docs).
## 💬 Discuss with us
Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
- [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For asynchronous discussions.)
- [Github Issues](https://github.com/TriliumNext/Notes/issues) (For bug reports and feature requests.)
## 🏗 Installation
### Windows / MacOS
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
### Linux
If your distribution is listed in the table below, use your distribution's package.
[![Packaging status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the `trilium` executable.
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
### Browser (any OS)
If you use a server installation (see below), you can directly access the web interface (which is almost identical to the desktop app).
Currently only the latest versions of Chrome & Firefox are supported (and tested).
### Mobile
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid). Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
See issue https://github.com/TriliumNext/Notes/issues/72 for more information on mobile app support.
### Server
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation).
You can also read [Patterns of personal knowledge base](https://github.com/zadam/trilium/wiki/Patterns-of-personal-knowledge-base) to get some inspiration on how you might use Trilium.
## 💻 Contribute ## 💻 Contribute
Use a browser based dev environment ### Code
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/zadam/trilium) Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
```shell
Or clone locally and run git clone https://github.com/TriliumNext/Notes.git
``` cd Notes
npm install pnpm install
npm run start-server pnpm run server:start
``` ```
## 📢 Shoutouts ### Documentation
Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:
```shell
git clone https://github.com/TriliumNext/Notes.git
cd Notes
pnpm install
pnpm nx run edit-docs:edit-docs
```
### Building the Executable
Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows:
```shell
git clone https://github.com/TriliumNext/Notes.git
cd Notes
pnpm install
pnpm nx --project=desktop electron-forge:make -- --arch=x64 --platform=win32
```
For more details, see the [development docs](https://github.com/TriliumNext/Notes/blob/develop/docs/Developer%20Guide/Developer%20Guide/Building%20and%20deployment/Running%20a%20development%20build.md).
### Developer Documentation
Please view the [documentation guide](./docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
## 👏 Shoutouts
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team * [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team
* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it. * [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. TriliumNext Notes would not be the same without it.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages * [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://github.com/zadam/trilium/wiki/Relation-map) and [link maps](https://github.com/zadam/trilium/wiki/Link-map) * [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
## 🤝 Support ## 🤝 Support
You can support Trilium using GitHub Sponsors, [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2). Support for the TriliumNext organization will be possible in the near future. For now, you can:
- Support continued development on TriliumNext by supporting our developers: [eliandoran](https://github.com/sponsors/eliandoran) (See the [repository insights]([developers]([url](https://github.com/TriliumNext/Notes/graphs/contributors))) for a full list)
- Show a token of gratitude to the original Trilium developer ([zadam](https://github.com/sponsors/zadam)) via [PayPal](https://paypal.me/za4am) or Bitcoin (bitcoin:bc1qv3svjn40v89mnkre5vyvs2xw6y8phaltl385d2).
## 🔑 License ## 🔑 License
Copyright 2017-2025 zadam, Elian Doran, and other contributors
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@@ -1,68 +0,0 @@
# Trilium Notes
[English](https://github.com/zadam/trilium/blob/master/README.md) | [Chinese](https://github.com/zadam/trilium/blob/master/README-ZH_CN.md) | [Russian](https://github.com/zadam/trilium/blob/master/README.ru.md) | [Japanese](https://github.com/zadam/trilium/blob/master/README.ja.md) | [Italian](https://github.com/zadam/trilium/blob/master/README.it.md)
[![Join the chat at https://gitter.im/trilium-notes/Lobby](https://badges.gitter.im/trilium-notes/Lobby.svg)](https://gitter.im/trilium-notes/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Trilium Notes это приложение для заметок с иерархической структурой, ориентированное на создание больших персональных баз знаний. Для быстрого ознакомления посмотрите [скриншот-тур](https://github.com/zadam/trilium/wiki/Screenshot-tour):
![](https://raw.githubusercontent.com/wiki/zadam/trilium/images/screenshot.png)
Ukraine is currently suffering from Russian aggression, please consider donating to [one of these charities](https://old.reddit.com/r/ukraine/comments/s6g5un/want_to_support_ukraine_heres_a_list_of_charities/).
<img src="https://upload.wikimedia.org/wikipedia/commons/4/49/Flag_of_Ukraine.svg" alt="drawing" width="600"/>
<img src="https://signmyrocket.com//uploads/2b2a523cd0c0e76cdbba95a89a9636b2_1676971281.jpg" alt="Trilium Notes supports Ukraine!" width="600"/>
## Возможности
* Заметки можно расположить в виде дерева произвольной глубины. Отдельную заметку можно разместить в нескольких местах дерева (см. [клонирование](https://github.com/zadam/trilium/wiki/Cloning-notes))
* Продвинутый визуальный редактор (WYSIWYG) позволяет работать с таблицами, изображениями, [формулами](https://github.com/zadam/trilium/wiki/Text-notes#math-support) и разметкой markdown, имеет [автоформатирование](https://github.com/zadam/trilium/wiki/Text-notes#autoformat)
* Редактирование [заметок с исходным кодом](https://github.com/zadam/trilium/wiki/Code-notes), включая подсветку синтаксиса
* Быстрая и простая [навигация между заметками](https://github.com/zadam/trilium/wiki/Note-navigation), полнотекстовый поиск и [выделение заметок](https://github.com/zadam/trilium/wiki/Note-hoisting) в отдельный блок
* Бесшовное [версионирование заметки](https://github.com/zadam/trilium/wiki/Note-revisions)
* Специальные [атрибуты](https://github.com/zadam/trilium/wiki/Attributes) позволяют гибко организовать структуру, используются для поиска и продвинутого [скриптинга](https://github.com/zadam/trilium/wiki/Scripts)
* [Синхронизация](https://github.com/zadam/trilium/wiki/Synchronization) заметок со своим сервером
* Надёжное [шифрование](https://github.com/zadam/trilium/wiki/Protected-notes) с детализацией по каждой заметке
* [Карты связей](https://github.com/zadam/trilium/wiki/Relation-map) и [карты ссылок](https://github.com/zadam/trilium/wiki/Link-map) для визуализации их взяимосвязей
* [Скрипты](https://github.com/zadam/trilium/wiki/Scripts) - см. [продвинутые примеры](https://github.com/zadam/trilium/wiki/Advanced-showcases)
* Хорошо масштабируется, как по удобству использования, так и по производительности до 100000 заметок
* Оптимизированный [мобильный фронтенд](https://github.com/zadam/trilium/wiki/Mobile-frontend) смартфонов и планшетов
* [Темная тема](https://github.com/zadam/trilium/wiki/Themes)
* Импорт и экпорт [Evernote](https://github.com/zadam/trilium/wiki/Evernote-import) и данных в [markdown](https://github.com/zadam/trilium/wiki/Markdown) формате
* [Web Clipper](https://github.com/zadam/trilium/wiki/Web-clipper) для удобного сохранения веб-контента
## Сборки
Trilium предоставляется в виде десктопного приложения (Linux и Windows) или веб-приложения, размещенного на вашем сервере (Linux). Доступна сборка Mac OS, но она [не поддерживается](https://github.com/zadam/trilium/wiki/FAQ#mac-os-support).
* Если вы хотите использовать Trilium на десктопе, скачайте архив для своей платформы со страницы [релизов](https://github.com/zadam/trilium/releases/latest), распакуйте и запустите исполняемый файл ```trilium```.
* Если вы хотите установить Trilium на сервере, следуйте этой [инструкции](https://github.com/zadam/trilium/wiki/Server-installation).
* В данный момент поддерживаются (протестированы) последние версии браузеров Chrome и Firefox.
## Документация
[Полный список страниц документации доступен в Wiki.](https://github.com/zadam/trilium/wiki/)
Вы также можете ознакомиться с [шаблонами персональных баз знаний](https://github.com/zadam/trilium/wiki/Patterns-of-personal-knowledge-base), чтобы получить представление о том, как можно использовать Trilium.
## Участвуйте в разработке
Используйте онлайн среду разработки в браузере
[![Открыть в Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/zadam/trilium)
Или склонируйте на своё устройство и запустите
```
npm install
npm run start-server
```
## Благодарности
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - лучший WYSIWYG редактор, очень активная и внимательная команда.
* [FancyTree](https://github.com/mar10/fancytree) - многофункциональная библиотека для создания древовидных структур. Вне конкуренции. Без него Trilium Notes не были бы таким.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - редактор кода с поддержкой огромного количество языков.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - библиотека для визуализации связей. Вне конкуренции. Используется в [картах связей](https://github.com/zadam/trilium/wiki/Relation-map) и [картах ссылок](https://github.com/zadam/trilium/wiki/Link-map).
## Лицензия
Эта программа является бесплатным программным обеспечением: вы можете распространять и/или изменять ее в соответствии с условиями GNU Affero General Public License, опубликованной Free Software Foundation, либо версии 3 Лицензии, либо (по вашему выбору) любой более поздней версии.

View File

@@ -10,4 +10,4 @@ Description above is a general rule and may be altered on case by case basis.
## Reporting a Vulnerability ## Reporting a Vulnerability
You can report low severity vulnerabilities as GitHub issues, more severe vulnerabilities should be reported to the email zadam.apps@gmail.com You can report low severity vulnerabilities as GitHub issues, more severe vulnerabilities should be reported to the email [contact@eliandoran.me](mailto:contact@eliandoran.me)

View File

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

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
if ! command -v magick &> /dev/null; then
echo "This tool requires ImageMagick to be installed in order to create the icons."
exit 1
fi
if ! command -v inkscape &> /dev/null; then
echo "This tool requires Inkscape to be render sharper SVGs than ImageMagick."
exit 1
fi
if ! command -v icnsutil &> /dev/null; then
echo "This tool requires icnsutil to be installed in order to generate macOS icons."
exit 1
fi
script_dir=$(realpath $(dirname $0))
cd "${script_dir}/../images/app-icons"
inkscape -w 180 -h 180 "../icon-color.svg" -o "./ios/apple-touch-icon.png"
# Build PNGs
inkscape -w 128 -h 128 "../icon-color.svg" -o "./png/128x128.png"
inkscape -w 256 -h 256 "../icon-color.svg" -o "./png/256x256.png"
# Build dev icons (including tray)
inkscape -w 16 -h 16 "../icon-purple.svg" -o "./png/16x16-dev.png"
inkscape -w 32 -h 32 "../icon-purple.svg" -o "./png/32x32-dev.png"
inkscape -w 256 -h 256 "../icon-purple.svg" -o "./png/256x256-dev.png"
# Build Mac .icns
declare -a sizes=("16" "32" "512" "1024")
for size in "${sizes[@]}"; do
inkscape -w $size -h $size "../icon-color.svg" -o "./png/${size}x${size}.png"
done
mkdir -p fakeapp.app
npx iconsur set fakeapp.app -l -i "png/1024x1024.png" -o "mac/1024x1024.png" -s 0.8
declare -a sizes=("16x16" "32x32" "128x128" "512x512")
for size in "${sizes[@]}"; do
magick "mac/1024x1024.png" -resize "${size}" "mac/${size}.png"
done
icnsutil compose -f "mac/icon.icns" ./mac/*.png
# Build Windows icon
magick -background none "../icon-color.svg" -define icon:auto-resize=16,32,48,64,128,256 "./icon.ico"
# Build Windows setup icon
magick -background none "../icon-installer.svg" -define icon:auto-resize=16,32,48,64,128,256 "./win/setup.ico"
# Build Squirrel splash image
magick "./png/256x256.png" -background "#ffffff" -gravity center -extent 640x480 "./win/setup-banner.gif"

0
bin/export-schema.sh → _regroup/bin/export-schema.sh Executable file → Normal file
View File

0
bin/generate-cert.sh → _regroup/bin/generate-cert.sh Executable file → Normal file
View File

View File

@@ -0,0 +1,95 @@
/**
* Usage: tsx ./generate_document.ts 1000
* will create 1000 new notes and some clones into the current document.db
*/
import sqlInit from "../src/services/sql_init.js";
import noteService from "../src/services/notes.js";
import attributeService from "../src/services/attributes.js";
import cls from "../src/services/cls.js";
import cloningService from "../src/services/cloning.js";
import loremIpsum from "lorem-ipsum";
import "../src/becca/entity_constructor.js";
const noteCount = parseInt(process.argv[2]);
if (!noteCount) {
console.error(`Please enter number of notes as program parameter.`);
process.exit(1);
}
const notes = ["root"];
function getRandomNoteId() {
const index = Math.floor(Math.random() * notes.length);
return notes[index];
}
async function start() {
for (let i = 0; i < noteCount; i++) {
const title = loremIpsum.loremIpsum({
count: 1,
units: "sentences",
sentenceLowerBound: 1,
sentenceUpperBound: 10
});
const paragraphCount = Math.floor(Math.random() * Math.random() * 100);
const content = loremIpsum.loremIpsum({
count: paragraphCount,
units: "paragraphs",
sentenceLowerBound: 1,
sentenceUpperBound: 15,
paragraphLowerBound: 3,
paragraphUpperBound: 10,
format: "html"
});
const { note } = noteService.createNewNote({
parentNoteId: getRandomNoteId(),
title,
content,
type: "text"
});
console.log(`Created note ${i}: ${title}`);
if (Math.random() < 0.04) {
const noteIdToClone = note.noteId;
const parentNoteId = getRandomNoteId();
const prefix = Math.random() > 0.8 ? "prefix" : "";
const result = await cloningService.cloneNoteToBranch(noteIdToClone, parentNoteId, prefix);
console.log(`Cloning ${i}:`, result.success ? "succeeded" : "FAILED");
}
// does not have to be for the current note
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "label",
name: "label",
value: "value",
isInheritable: Math.random() > 0.1 // 10% are inheritable
});
await attributeService.createAttribute({
noteId: getRandomNoteId(),
type: "relation",
name: "relation",
value: getRandomNoteId(),
isInheritable: Math.random() > 0.1 // 10% are inheritable
});
note.saveRevision();
notes.push(note.noteId);
}
process.exit(0);
}
// @TriliumNextTODO sqlInit.dbReady never seems to resolve so program hangs
// see https://github.com/TriliumNext/Notes/issues/1020
sqlInit.dbReady.then(cls.wrap(start)).catch((err) => console.error(err));

View File

View File

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

@@ -0,0 +1,57 @@
#!/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
echo "Updating master"
git fetch
git checkout master
git reset --hard origin/master
git merge origin/develop
git push

110
_regroup/bin/translation.sh Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# --------------------------------------------------------------------------------------------------
#
# Create PO files to make easier the labor of translation.
#
# Info:
# https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
# https://docs.translatehouse.org/projects/translate-toolkit/en/latest/commands/json2po.html
#
# Dependencies:
# jq
# translate-toolkit
# python-wcwidth
#
# Created by @hasecilu
#
# --------------------------------------------------------------------------------------------------
number_of_keys() {
[ -f "$1" ] && jq 'path(..) | select(length == 2) | .[1]' "$1" | wc -l || echo "0"
}
stats() {
# Print the number of existing strings on the JSON files for each locale
s=$(number_of_keys "${paths[0]}/en/server.json")
c=$(number_of_keys "${paths[1]}/en/translation.json")
echo "| locale |server strings |client strings |"
echo "|--------|---------------|---------------|"
echo "| en | ${s} | ${c} |"
for locale in "${locales[@]}"; do
s=$(number_of_keys "${paths[0]}/${locale}/server.json")
c=$(number_of_keys "${paths[1]}/${locale}/translation.json")
n1=$(((8 - ${#locale}) / 2))
n2=$((n1 == 1 ? n1 + 1 : n1))
echo "|$(printf "%${n1}s")${locale}$(printf "%${n2}s")| ${s} | ${c} |"
done
}
update_1() {
# Update PO files from English and localized JSON files as source
# NOTE: if you want a new language you need to first create the JSON files
# on their corresponding place with `{}` as content to avoid error on `json2po`
local locales=("$@")
for path in "${paths[@]}"; do
for locale in "${locales[@]}"; do
json2po -t "${path}/en" "${path}/${locale}" "${path}/po-${locale}"
done
done
}
update_2() {
# Recover translation from PO files to localized JSON files
local locales=("$@")
for path in "${paths[@]}"; do
for locale in "${locales[@]}"; do
po2json -t "${path}/en" "${path}/po-${locale}" "${path}/${locale}"
done
done
}
help() {
echo -e "\nDescription:"
echo -e "\tCreate PO files to make easier the labor of translation"
echo -e "\nUsage:"
echo -e "\t./translation.sh [--stats] [--update1 <OPT_LOCALE>] [--update2 <OPT_LOCALE>]"
echo -e "\nFlags:"
echo -e " --clear\n\tClear all po-* directories"
echo -e " --stats\n\tPrint the number of existing strings on the JSON files for each locale"
echo -e " --update1 <LOCALE>\n\tUpdate PO files from English and localized JSON files as source"
echo -e " --update2 <LOCALE>\n\tRecover translation from PO files to localized JSON files"
}
# Main function ------------------------------------------------------------------------------------
# Get script directory to set file path relative to it
file_path="$(
cd -- "$(dirname "${0}")" >/dev/null 2>&1 || exit
pwd -P
)"
paths=("${file_path}/../translations/" "${file_path}/../src/public/translations/")
locales=(cn de es fr pt_br ro tw)
if [ $# -eq 1 ]; then
if [ "$1" == "--clear" ]; then
for path in "${paths[@]}"; do
for locale in "${locales[@]}"; do
[ -d "${path}/po-${locale}" ] && rm -r "${path}/po-${locale}"
done
done
elif [ "$1" == "--stats" ]; then
stats
elif [ "$1" == "--update1" ]; then
update_1 "${locales[@]}"
elif [ "$1" == "--update2" ]; then
update_2 "${locales[@]}"
else
help
fi
elif [ $# -eq 2 ]; then
if [ "$1" == "--update1" ]; then
update_1 "$2"
elif [ "$1" == "--update2" ]; then
update_2 "$2"
else
help
fi
else
help
fi

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-bookmark"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 7v14l-6 -4l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4z" /></svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
if ! command -v magick &> /dev/null; then
echo "This tool requires ImageMagick to be installed in order to create the icons."
exit 1
fi
if ! command -v inkscape &> /dev/null; then
echo "This tool requires Inkscape to be render sharper SVGs than ImageMagick."
exit 1
fi
script_dir=$(realpath $(dirname $0))
images_dir="$script_dir/../../images"
output_dir="$images_dir/app-icons/tray"
function generateDpiScaledIcons {
file=$1
suffix=$2
name="$(basename $file .svg)$suffix"
inkscape -w 16 -h 16 "$file" -o "$output_dir/$name.png"
inkscape -w 20 -h 20 "$file" -o "$output_dir/$name@1.25x.png"
inkscape -w 24 -h 24 "$file" -o "$output_dir/$name@1.5x.png"
inkscape -w 32 -h 32 "$file" -o "$output_dir/$name@2x.png"
}
generateDpiScaledIcons "$images_dir/icon-black.svg" "Template"
generateDpiScaledIcons "$images_dir/icon-color.svg"
generateDpiScaledIcons "$images_dir/icon-purple.svg"
for file in *.svg; do
name="$(basename $file .svg)Template"
generateDpiScaledIcons "$file" "Template"
magick "$output_dir/$name.png" -channel RGB -negate "$output_dir/$name-inverted.png"
magick "$output_dir/$name@1.25x.png" -channel RGB -negate "$output_dir/$name-inverted@1.25x.png"
magick "$output_dir/$name@1.5x.png" -channel RGB -negate "$output_dir/$name-inverted@1.5x.png"
magick "$output_dir/$name@2x.png" -channel RGB -negate "$output_dir/$name-inverted@2x.png"
done

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-x"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18 6l-12 12" /><path d="M6 6l12 12" /></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-plus"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 5l0 14" /><path d="M5 12l14 0" /></svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-history"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 8l0 4l2 2" /><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" /></svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-calendar-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 21h-5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3.5" /><path d="M16 3v4" /><path d="M8 3v4" /><path d="M4 11h11" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" /></svg>

After

Width:  |  Height:  |  Size: 734 B

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

58
_regroup/package.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

15
_regroup/typedoc.json Normal file
View File

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

4
apps/client/.env Normal file
View File

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

View File

@@ -0,0 +1 @@
VITE_CKEDITOR_ENABLE_INSPECTOR=false

8
apps/client/.swcrc Normal file
View File

@@ -0,0 +1,8 @@
{
"jsc": {
"parser": {
"syntax": "typescript"
},
"target": "es2016"
}
}

View File

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

84
apps/client/package.json Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "@triliumnext/client",
"version": "0.95.0",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
"author": {
"name": "TriliumNext Notes Team",
"email": "contact@eliandoran.me",
"url": "https://github.com/TriliumNext/Notes"
},
"dependencies": {
"@eslint/js": "9.29.0",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.17",
"@fullcalendar/daygrid": "6.1.17",
"@fullcalendar/interaction": "6.1.17",
"@fullcalendar/list": "6.1.17",
"@fullcalendar/multimonth": "6.1.17",
"@fullcalendar/timegrid": "6.1.17",
"@mermaid-js/layout-elk": "0.1.7",
"@mind-elixir/node-menu": "1.0.5",
"@popperjs/core": "2.11.8",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.7",
"boxicons": "2.1.4",
"dayjs": "1.11.13",
"dayjs-plugin-utc": "0.1.2",
"debounce": "2.2.0",
"draggabilly": "3.0.0",
"force-graph": "1.49.6",
"globals": "16.2.0",
"i18next": "25.2.1",
"i18next-http-backend": "3.0.2",
"jquery": "3.7.1",
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.22",
"knockout": "3.5.1",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "15.0.12",
"mermaid": "11.6.0",
"mind-elixir": "4.6.1",
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.26.9",
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"vanilla-js-wheel-zoom": "9.0.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "4.1.0",
"@types/bootstrap": "5.2.10",
"@types/jquery": "3.5.32",
"@types/leaflet": "1.9.18",
"@types/leaflet-gpx": "1.3.7",
"@types/mark.js": "8.11.12",
"copy-webpack-plugin": "13.0.0",
"happy-dom": "18.0.1",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.0.2"
},
"nx": {
"name": "client",
"targets": {
"serve": {
"dependsOn": [
"^build"
]
},
"circular-deps": {
"command": "pnpx dpdm -T {projectRoot}/src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
}
}
}
}

View File

@@ -0,0 +1,3 @@
import packageJson from "../package.json" with { type: "json" };
export default `assets/v${packageJson.version}`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,17 @@
{
"name": "Trilium Notes",
"short_name": "Trilium",
"description": "Trilium Notes is a hierarchical note taking application with focus on building large personal knowledge bases.",
"theme_color": "#333333",
"background_color": "#1F1F1F",
"display": "standalone",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "icon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,608 @@
import froca from "../services/froca.js";
import RootCommandExecutor from "./root_command_executor.js";
import Entrypoints, { type SqlExecuteResults } from "./entrypoints.js";
import options from "../services/options.js";
import utils, { hasTouchBar } from "../services/utils.js";
import zoomComponent from "./zoom.js";
import TabManager from "./tab_manager.js";
import Component from "./component.js";
import keyboardActionsService from "../services/keyboard_actions.js";
import linkService, { type ViewScope } from "../services/link.js";
import MobileScreenSwitcherExecutor, { type Screen } from "./mobile_screen_switcher.js";
import MainTreeExecutors from "./main_tree_executors.js";
import toast from "../services/toast.js";
import ShortcutComponent from "./shortcut_component.js";
import { t, initLocale } from "../services/i18n.js";
import type NoteDetailWidget from "../widgets/note_detail.js";
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
import type LoadResults from "../services/load_results.js";
import type { Attribute } from "../services/attribute_parser.js";
import type NoteTreeWidget from "../widgets/note_tree.js";
import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type EditableTextTypeWidget from "../widgets/type_widgets/editable_text.js";
import type { NativeImage, TouchBar } from "electron";
import TouchBarComponent from "./touch_bar.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { StartupChecks } from "./startup_checks.js";
interface Layout {
getRootWidget: (appContext: AppContext) => RootWidget;
}
interface RootWidget extends Component {
render: () => JQuery<HTMLElement>;
}
interface BeforeUploadListener extends Component {
beforeUnloadEvent(): boolean;
}
/**
* Base interface for the data/arguments for a given command (see {@link CommandMappings}).
*/
export interface CommandData {
ntxId?: string | null;
}
/**
* Represents a set of commands that are triggered from the context menu, providing information such as the selected note.
*/
export interface ContextMenuCommandData extends CommandData {
node: Fancytree.FancytreeNode;
notePath?: string;
noteId?: string;
selectedOrActiveBranchIds: string[];
selectedOrActiveNoteIds?: string[];
}
export interface NoteCommandData extends CommandData {
notePath?: string | null;
hoistedNoteId?: string | null;
viewScope?: ViewScope;
}
export interface ExecuteCommandData<T> extends CommandData {
resolve: (data: T) => void;
}
export interface NoteSwitchedContext {
noteContext: NoteContext;
notePath: string | null | undefined;
}
/**
* The keys represent the different commands that can be triggered via {@link AppContext#triggerCommand} (first argument), and the values represent the data or arguments definition of the given command. All data for commands must extend {@link CommandData}.
*/
export type CommandMappings = {
"api-log-messages": CommandData;
focusTree: CommandData;
focusOnTitle: CommandData;
focusOnDetail: CommandData;
focusOnSearchDefinition: Required<CommandData>;
searchNotes: CommandData & {
searchString?: string;
ancestorNoteId?: string | null;
};
closeTocCommand: CommandData;
closeHlt: CommandData;
showLaunchBarSubtree: CommandData;
showRevisions: CommandData;
showLlmChat: CommandData;
createAiChat: CommandData;
showOptions: CommandData & {
section: string;
};
showExportDialog: CommandData & {
notePath: string;
defaultType: "single" | "subtree";
};
showDeleteNotesDialog: CommandData & {
branchIdsToDelete: string[];
callback: (value: ResolveOptions) => void;
forceDeleteAllClones: boolean;
};
showConfirmDeleteNoteBoxWithNoteDialog: ConfirmWithTitleOptions;
openedFileUpdated: CommandData & {
entityType: string;
entityId: string;
lastModifiedMs: number;
filePath: string;
};
focusAndSelectTitle: CommandData & {
isNewNote?: boolean;
};
showPromptDialog: PromptDialogOptions;
showInfoDialog: ConfirmWithMessageOptions;
showConfirmDialog: ConfirmWithMessageOptions;
showRecentChanges: CommandData & { ancestorNoteId: string };
showImportDialog: CommandData & { noteId: string };
openNewNoteSplit: NoteCommandData;
openInWindow: NoteCommandData;
openNoteInNewTab: CommandData;
openNoteInNewSplit: CommandData;
openNoteInNewWindow: CommandData;
openAboutDialog: CommandData;
hideFloatingButtons: {};
hideLeftPane: CommandData;
showCpuArchWarning: CommandData;
showLeftPane: CommandData;
hoistNote: CommandData & { noteId: string };
leaveProtectedSession: CommandData;
enterProtectedSession: CommandData;
noteContextReorder: CommandData & {
ntxIdsInOrder: string[];
oldMainNtxId?: string | null;
newMainNtxId?: string | null;
};
openInTab: ContextMenuCommandData;
openNoteInSplit: ContextMenuCommandData;
toggleNoteHoisting: ContextMenuCommandData;
insertNoteAfter: ContextMenuCommandData;
insertChildNote: ContextMenuCommandData;
delete: ContextMenuCommandData;
editNoteTitle: {};
protectSubtree: ContextMenuCommandData;
unprotectSubtree: ContextMenuCommandData;
openBulkActionsDialog:
| ContextMenuCommandData
| {
selectedOrActiveNoteIds?: string[];
};
editBranchPrefix: ContextMenuCommandData;
convertNoteToAttachment: ContextMenuCommandData;
duplicateSubtree: ContextMenuCommandData;
expandSubtree: ContextMenuCommandData;
collapseSubtree: ContextMenuCommandData;
sortChildNotes: ContextMenuCommandData;
copyNotePathToClipboard: ContextMenuCommandData;
recentChangesInSubtree: ContextMenuCommandData;
cutNotesToClipboard: ContextMenuCommandData;
copyNotesToClipboard: ContextMenuCommandData;
pasteNotesFromClipboard: ContextMenuCommandData;
pasteNotesAfterFromClipboard: ContextMenuCommandData;
moveNotesTo: ContextMenuCommandData;
cloneNotesTo: ContextMenuCommandData;
deleteNotes: ContextMenuCommandData;
importIntoNote: ContextMenuCommandData;
exportNote: ContextMenuCommandData;
searchInSubtree: ContextMenuCommandData;
moveNoteUp: ContextMenuCommandData;
moveNoteDown: ContextMenuCommandData;
moveNoteUpInHierarchy: ContextMenuCommandData;
moveNoteDownInHierarchy: ContextMenuCommandData;
selectAllNotesInParent: ContextMenuCommandData;
createNoteIntoInbox: CommandData;
addNoteLauncher: ContextMenuCommandData;
addScriptLauncher: ContextMenuCommandData;
addWidgetLauncher: ContextMenuCommandData;
addSpacerLauncher: ContextMenuCommandData;
moveLauncherToVisible: ContextMenuCommandData;
moveLauncherToAvailable: ContextMenuCommandData;
resetLauncher: ContextMenuCommandData;
executeInActiveNoteDetailWidget: CommandData & {
callback: (value: NoteDetailWidget | PromiseLike<NoteDetailWidget>) => void;
};
executeWithTextEditor: CommandData &
ExecuteCommandData<CKTextEditor> & {
callback?: GetTextEditorCallback;
};
executeWithCodeEditor: CommandData & ExecuteCommandData<CodeMirror>;
/**
* Called upon when attempting to retrieve the content element of a {@link NoteContext}.
* Generally should not be invoked manually, as it is used by {@link NoteContext.getContentElement}.
*/
executeWithContentElement: CommandData & ExecuteCommandData<JQuery<HTMLElement>>;
executeWithTypeWidget: CommandData & ExecuteCommandData<TypeWidget | null>;
addTextToActiveEditor: CommandData & {
text: string;
};
/** Works only in the electron context menu. */
replaceMisspelling: CommandData;
importMarkdownInline: CommandData;
showPasswordNotSet: CommandData;
showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string };
closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData;
updateAttributesList: {
attributes: Attribute[];
};
addNewLabel: CommandData;
addNewRelation: CommandData;
addNewLabelDefinition: CommandData;
addNewRelationDefinition: CommandData;
cloneNoteIdsTo: CommandData & {
noteIds: string[];
};
moveBranchIdsTo: CommandData & {
branchIds: string[];
};
/** Sets the active {@link Screen} (e.g. to toggle the tree sidebar). It triggers the {@link EventMappings.activeScreenChanged} event, but only if the provided <em>screen</em> is different than the current one. */
setActiveScreen: CommandData & {
screen: Screen;
};
closeTab: CommandData;
closeToc: CommandData;
closeOtherTabs: CommandData;
closeRightTabs: CommandData;
closeAllTabs: CommandData;
reopenLastTab: CommandData;
moveTabToNewWindow: CommandData;
copyTabToNewWindow: CommandData;
closeActiveTab: CommandData & {
$el: JQuery<HTMLElement>;
};
setZoomFactorAndSave: {
zoomFactor: string;
};
reEvaluateRightPaneVisibility: CommandData;
runActiveNote: CommandData;
scrollContainerToCommand: CommandData & {
position: number;
};
scrollToEnd: CommandData;
closeThisNoteSplit: CommandData;
moveThisNoteSplit: CommandData & { isMovingLeft: boolean };
jumpToNote: CommandData;
// Geomap
deleteFromMap: { noteId: string };
openGeoLocation: { noteId: string; event: JQuery.MouseDownEvent };
toggleZenMode: CommandData;
updateAttributeList: CommandData & { attributes: Attribute[] };
saveAttributes: CommandData;
reloadAttributes: CommandData;
refreshNoteList: CommandData & { noteId: string };
refreshResults: {};
refreshSearchDefinition: {};
geoMapCreateChildNote: CommandData;
buildTouchBar: CommandData & {
TouchBar: typeof TouchBar;
buildIcon(name: string): NativeImage;
};
refreshTouchBar: CommandData;
reloadTextEditor: CommandData;
};
type EventMappings = {
initialRenderComplete: {};
frocaReloaded: {};
setLeftPaneVisibility: {
leftPaneVisible: boolean | null;
}
protectedSessionStarted: {};
notesReloaded: {
noteIds: string[];
};
refreshIncludedNote: {
noteId: string;
};
apiLogMessages: {
noteId: string;
messages: string[];
};
entitiesReloaded: {
loadResults: LoadResults;
};
addNewLabel: CommandData;
addNewRelation: CommandData;
sqlQueryResults: CommandData & {
results: SqlExecuteResults;
};
readOnlyTemporarilyDisabled: {
noteContext: NoteContext;
};
/** Triggered when the {@link CommandMappings.setActiveScreen} command is invoked. */
activeScreenChanged: {
activeScreen: Screen;
};
activeContextChanged: {
noteContext: NoteContext;
};
beforeNoteSwitch: {
noteContext: NoteContext;
};
beforeNoteContextRemove: {
ntxIds: string[];
};
noteSwitched: NoteSwitchedContext;
noteSwitchedAndActivated: NoteSwitchedContext;
setNoteContext: {
noteContext: NoteContext;
};
reEvaluateHighlightsListWidgetVisibility: {
noteId: string | undefined;
};
reEvaluateTocWidgetVisibility: {
noteId: string | undefined;
};
showHighlightsListWidget: {
noteId: string;
};
showTocWidget: {
noteId: string;
};
showSearchError: {
error: string;
};
searchRefreshed: { ntxId?: string | null };
hoistedNoteChanged: {
noteId: string;
ntxId: string | null;
};
contextsReopened: {
ntxId?: string;
mainNtxId: string | null;
tabPosition: number;
afterNtxId?: string;
};
noteDetailRefreshed: {
ntxId?: string | null;
};
noteContextReorder: {
oldMainNtxId: string;
newMainNtxId: string;
ntxIdsInOrder: string[];
};
newNoteContextCreated: {
noteContext: NoteContext;
};
noteContextRemoved: {
ntxIds: string[];
};
exportSvg: { ntxId: string | null | undefined; };
exportPng: { ntxId: string | null | undefined; };
geoMapCreateChildNote: {
ntxId: string | null | undefined; // TODO: deduplicate ntxId
};
tabReorder: {
ntxIdsInOrder: string[];
};
refreshNoteList: {
noteId: string;
};
noteTypeMimeChanged: { noteId: string };
zenModeChanged: { isEnabled: boolean };
relationMapCreateChildNote: { ntxId: string | null | undefined };
relationMapResetPanZoom: { ntxId: string | null | undefined };
relationMapResetZoomIn: { ntxId: string | null | undefined };
relationMapResetZoomOut: { ntxId: string | null | undefined };
activeNoteChanged: {};
showAddLinkDialog: {
textTypeWidget: EditableTextTypeWidget;
text: string;
};
showIncludeDialog: {
textTypeWidget: EditableTextTypeWidget;
};
openBulkActionsDialog: {
selectedOrActiveNoteIds: string[];
};
cloneNoteIdsTo: {
noteIds: string[];
};
refreshData: { ntxId: string | null | undefined };
};
export type EventListener<T extends EventNames> = {
[key in T as `${key}Event`]: (data: EventData<T>) => void;
};
export type CommandListener<T extends CommandNames> = {
[key in T as `${key}Command`]: (data: CommandListenerData<T>) => void;
};
export type CommandListenerData<T extends CommandNames> = CommandMappings[T];
type CommandAndEventMappings = CommandMappings & EventMappings;
type EventOnlyNames = keyof EventMappings;
export type EventNames = CommandNames | EventOnlyNames;
export type EventData<T extends EventNames> = CommandAndEventMappings[T];
/**
* This type is a discriminated union which contains all the possible commands that can be triggered via {@link AppContext.triggerCommand}.
*/
export type CommandNames = keyof CommandMappings;
type FilterByValueType<T, ValueType> = { [K in keyof T]: T[K] extends ValueType ? K : never }[keyof T];
/**
* Generic which filters {@link CommandNames} to provide only those commands that take in as data the desired implementation of {@link CommandData}. Mostly useful for contextual menu, to enforce consistency in the commands.
*/
export type FilteredCommandNames<T extends CommandData> = keyof Pick<CommandMappings, FilterByValueType<CommandMappings, T>>;
export class AppContext extends Component {
isMainWindow: boolean;
components: Component[];
beforeUnloadListeners: WeakRef<BeforeUploadListener>[];
tabManager!: TabManager;
layout?: Layout;
noteTreeWidget?: NoteTreeWidget;
lastSearchString?: string;
constructor(isMainWindow: boolean) {
super();
this.isMainWindow = isMainWindow;
// non-widget/layout components needed for the application
this.components = [];
this.beforeUnloadListeners = [];
}
/**
* Must be called as soon as possible, before the creation of any components since this method is in charge of initializing the locale. Any attempts to read translation before this method is called will result in `undefined`.
*/
async earlyInit() {
await options.initializedPromise;
await initLocale();
}
setLayout(layout: Layout) {
this.layout = layout;
}
async start() {
this.initComponents();
this.renderWidgets();
await froca.initializedPromise;
this.tabManager.loadTabs();
const bundleService = (await import("../services/bundle.js")).default;
setTimeout(() => bundleService.executeStartupBundles(), 2000);
}
initComponents() {
this.tabManager = new TabManager();
this.components = [
this.tabManager,
new RootCommandExecutor(),
new Entrypoints(),
new MainTreeExecutors(),
new ShortcutComponent(),
new StartupChecks()
];
if (utils.isMobile()) {
this.components.push(new MobileScreenSwitcherExecutor());
}
for (const component of this.components) {
this.child(component);
}
if (utils.isElectron()) {
this.child(zoomComponent);
}
if (hasTouchBar) {
this.child(new TouchBarComponent());
}
}
renderWidgets() {
if (!this.layout) {
throw new Error("Missing layout.");
}
const rootWidget = this.layout.getRootWidget(this);
const $renderedWidget = rootWidget.render();
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
$("body").append($renderedWidget);
$renderedWidget.on("click", "[data-trigger-command]", function () {
if ($(this).hasClass("disabled")) {
return;
}
const commandName = $(this).attr("data-trigger-command");
const $component = $(this).closest(".component");
const component = $component.prop("component");
component.triggerCommand(commandName, { $el: $(this) });
});
this.child(rootWidget);
this.triggerEvent("initialRenderComplete", {});
}
triggerEvent<K extends EventNames>(name: K, data: EventData<K>) {
return this.handleEvent(name, data);
}
triggerCommand<K extends CommandNames>(name: K, _data?: CommandMappings[K]) {
const data = _data || {};
for (const executor of this.components) {
const fun = (executor as any)[`${name}Command`];
if (fun) {
return executor.callMethod(fun, data);
}
}
// this might hint at error, but sometimes this is used by components which are at different places
// in the component tree to communicate with each other
console.debug(`Unhandled command ${name}, converting to event.`);
return this.triggerEvent(name, data as CommandAndEventMappings[K]);
}
getComponentByEl(el: HTMLElement) {
return $(el).closest(".component").prop("component");
}
addBeforeUnloadListener(obj: BeforeUploadListener) {
if (typeof WeakRef !== "function") {
// older browsers don't support WeakRef
return;
}
this.beforeUnloadListeners.push(new WeakRef<BeforeUploadListener>(obj));
}
}
const appContext = new AppContext(window.glob.isMainWindow);
// we should save all outstanding changes before the page/app is closed
$(window).on("beforeunload", () => {
let allSaved = true;
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter((wr) => !!wr.deref());
for (const weakRef of appContext.beforeUnloadListeners) {
const component = weakRef.deref();
if (!component) {
continue;
}
if (!component.beforeUnloadEvent()) {
console.log(`Component ${component.componentId} is not finished saving its state.`);
toast.showMessage(t("app_context.please_wait_for_save"), 10000);
allSaved = false;
}
}
if (!allSaved) {
return "some string";
}
});
$(window).on("hashchange", function () {
const { notePath, ntxId, viewScope, searchString } = linkService.parseNavigationStateFromUrl(window.location.href);
if (notePath || ntxId) {
appContext.tabManager.switchToNoteContext(ntxId, notePath, viewScope);
} else if (searchString) {
appContext.triggerCommand("searchNotes", { searchString });
}
});
export default appContext;

View File

@@ -0,0 +1,129 @@
import utils from "../services/utils.js";
import type { CommandMappings, CommandNames, EventData, EventNames } from "./app_context.js";
/**
* Abstract class for all components in the Trilium's frontend.
*
* Contains also event implementation with following properties:
* - event / command distribution is synchronous which among others mean that events are well-ordered - event
* which was sent out first will also be processed first by the component
* - execution of the event / command is asynchronous - each component executes the event on its own without regard for
* other components.
* - although the execution is async, we are collecting all the promises, and therefore it is possible to wait until the
* event / command is executed in all components - by simply awaiting the `triggerEvent()`.
*/
export class TypedComponent<ChildT extends TypedComponent<ChildT>> {
$widget!: JQuery<HTMLElement>;
componentId: string;
children: ChildT[];
initialized: Promise<void> | null;
parent?: TypedComponent<any>;
_position!: number;
constructor() {
this.componentId = `${this.sanitizedClassName}-${utils.randomString(8)}`;
this.children = [];
this.initialized = null;
}
get sanitizedClassName() {
// webpack mangles names and sometimes uses unsafe characters
return this.constructor.name.replace(/[^A-Z0-9]/gi, "_");
}
get position() {
return this._position;
}
set position(newPosition: number) {
this._position = newPosition;
}
setParent(parent: TypedComponent<any>) {
this.parent = parent;
return this;
}
child(...components: ChildT[]) {
for (const component of components) {
component.setParent(this);
this.children.push(component);
}
return this;
}
handleEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null | undefined {
try {
const callMethodPromise = this.initialized ? this.initialized.then(() => this.callMethod((this as any)[`${name}Event`], data)) : this.callMethod((this as any)[`${name}Event`], data);
const childrenPromise = this.handleEventInChildren(name, data);
// don't create promises if not needed (optimization)
return callMethodPromise && childrenPromise ? Promise.all([callMethodPromise, childrenPromise]) : callMethodPromise || childrenPromise;
} catch (e: any) {
console.error(`Handling of event '${name}' failed in ${this.constructor.name} with error ${e.message} ${e.stack}`);
return null;
}
}
triggerEvent<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown> | undefined | null {
return this.parent?.triggerEvent(name, data);
}
handleEventInChildren<T extends EventNames>(name: T, data: EventData<T>): Promise<unknown[] | unknown> | null {
const promises: Promise<unknown>[] = [];
for (const child of this.children) {
const ret = child.handleEvent(name, data) as Promise<void>;
if (ret) {
promises.push(ret);
}
}
// don't create promises if not needed (optimization)
return promises.length > 0 ? Promise.all(promises) : null;
}
triggerCommand<K extends CommandNames>(name: K, data?: CommandMappings[K]): Promise<unknown> | undefined | null {
const fun = (this as any)[`${name}Command`];
if (fun) {
return this.callMethod(fun, data);
} else {
if (!this.parent) {
throw new Error(`Component "${this.componentId}" does not have a parent attached to propagate a command.`);
}
return this.parent.triggerCommand(name, data);
}
}
callMethod(fun: (arg: unknown) => Promise<unknown>, data: unknown) {
if (typeof fun !== "function") {
return;
}
const startTime = Date.now();
const promise = fun.call(this, data);
const took = Date.now() - startTime;
if (glob.isDev && took > 20) {
// measuring only sync handlers
console.log(`Call to ${fun.name} in ${this.componentId} took ${took}ms`);
}
if (glob.isDev && promise) {
return utils.timeLimit(promise, 20000, `Time limit failed on ${this.constructor.name} with ${fun.name}`);
}
return promise;
}
}
export default class Component extends TypedComponent<Component> {}

View File

@@ -0,0 +1,233 @@
import utils from "../services/utils.js";
import dateNoteService from "../services/date_notes.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import appContext, { type NoteCommandData } from "./app_context.js";
import Component from "./component.js";
import toastService from "../services/toast.js";
import ws from "../services/ws.js";
import bundleService from "../services/bundle.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import { t } from "../services/i18n.js";
import type FNote from "../entities/fnote.js";
// TODO: Move somewhere else nicer.
export type SqlExecuteResults = string[][][];
// TODO: Deduplicate with server.
interface SqlExecuteResponse {
success: boolean;
error?: string;
results: SqlExecuteResults;
}
// TODO: Deduplicate with server.
interface CreateChildrenResponse {
note: FNote;
}
export default class Entrypoints extends Component {
constructor() {
super();
if (jQuery.hotkeys) {
// hot keys are active also inside inputs and content editables
jQuery.hotkeys.options.filterInputAcceptingElements = false;
jQuery.hotkeys.options.filterContentEditable = false;
jQuery.hotkeys.options.filterTextInputs = false;
}
}
openDevToolsCommand() {
if (utils.isElectron()) {
utils.dynamicRequire("@electron/remote").getCurrentWindow().toggleDevTools();
}
}
async createNoteIntoInboxCommand() {
const inboxNote = await dateNoteService.getInboxNote();
if (!inboxNote) {
console.warn("Missing inbox note.");
return;
}
const { note } = await server.post<CreateChildrenResponse>(`notes/${inboxNote.noteId}/children?target=into`, {
content: "",
type: "text",
isProtected: inboxNote.isProtected && protectedSessionHolder.isProtectedSessionAvailable()
});
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.openTabWithNoteWithHoisting(note.noteId, { activate: true });
appContext.triggerEvent("focusAndSelectTitle", { isNewNote: true });
}
async toggleNoteHoistingCommand({ noteId = appContext.tabManager.getActiveContextNoteId() }) {
const activeNoteContext = appContext.tabManager.getActiveContext();
if (!activeNoteContext || !noteId) {
return;
}
const noteToHoist = await froca.getNote(noteId);
if (noteToHoist?.noteId === activeNoteContext.hoistedNoteId) {
await activeNoteContext.unhoist();
} else if (noteToHoist?.type !== "search") {
await activeNoteContext.setHoistedNoteId(noteId);
}
}
async hoistNoteCommand({ noteId }: { noteId: string }) {
const noteContext = appContext.tabManager.getActiveContext();
if (!noteContext) {
logError("hoistNoteCommand: noteContext is null");
return;
}
if (noteContext.hoistedNoteId !== noteId) {
await noteContext.setHoistedNoteId(noteId);
}
}
async unhoistCommand() {
const activeNoteContext = appContext.tabManager.getActiveContext();
if (activeNoteContext) {
activeNoteContext.unhoist();
}
}
copyWithoutFormattingCommand() {
utils.copySelectionToClipboard();
}
toggleFullscreenCommand() {
if (utils.isElectron()) {
const win = utils.dynamicRequire("@electron/remote").getCurrentWindow();
if (win.isFullScreenable()) {
win.setFullScreen(!win.isFullScreen());
}
} // outside of electron this is handled by the browser
}
reloadFrontendAppCommand() {
utils.reloadFrontendApp();
}
async logoutCommand() {
await server.post("../logout");
window.location.replace(`/login`);
}
backInNoteHistoryCommand() {
if (utils.isElectron()) {
// standard JS version does not work completely correctly in electron
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
webContents.goToIndex(activeIndex - 1);
} else {
window.history.back();
}
}
forwardInNoteHistoryCommand() {
if (utils.isElectron()) {
// standard JS version does not work completely correctly in electron
const webContents = utils.dynamicRequire("@electron/remote").getCurrentWebContents();
const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex());
webContents.goToIndex(activeIndex + 1);
} else {
window.history.forward();
}
}
async switchToDesktopVersionCommand() {
utils.setCookie("trilium-device", "desktop");
utils.reloadFrontendApp("Switching to desktop version");
}
async switchToMobileVersionCommand() {
utils.setCookie("trilium-device", "mobile");
utils.reloadFrontendApp("Switching to mobile version");
}
async openInWindowCommand({ notePath, hoistedNoteId, viewScope }: NoteCommandData) {
const extraWindowHash = linkService.calculateHash({ notePath, hoistedNoteId, viewScope });
if (utils.isElectron()) {
const { ipcRenderer } = utils.dynamicRequire("electron");
ipcRenderer.send("create-extra-window", { extraWindowHash });
} else {
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}?extraWindow=1${extraWindowHash}`;
window.open(url, "", "width=1000,height=800");
}
}
async openNewWindowCommand() {
this.openInWindowCommand({ notePath: "", hoistedNoteId: "root" });
}
async runActiveNoteCommand() {
const noteContext = appContext.tabManager.getActiveContext();
if (!noteContext) {
return;
}
const { ntxId, note } = noteContext;
// ctrl+enter is also used elsewhere, so make sure we're running only when appropriate
if (!note || note.type !== "code") {
return;
}
// TODO: use note.executeScript()
if (note.mime.endsWith("env=frontend")) {
await bundleService.getAndExecuteBundle(note.noteId);
} else if (note.mime.endsWith("env=backend")) {
await server.post(`script/run/${note.noteId}`);
} else if (note.mime === "text/x-sqlite;schema=trilium") {
const resp = await server.post<SqlExecuteResponse>(`sql/execute/${note.noteId}`);
if (!resp.success) {
toastService.showError(t("entrypoints.sql-error", { message: resp.error }));
}
await appContext.triggerEvent("sqlQueryResults", { ntxId: ntxId, results: resp.results });
}
toastService.showMessage(t("entrypoints.note-executed"));
}
hideAllPopups() {
if (utils.isDesktop()) {
$(".aa-input").autocomplete("close");
}
}
noteSwitchedEvent() {
this.hideAllPopups();
}
activeContextChangedEvent() {
this.hideAllPopups();
}
async forceSaveRevisionCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
await server.post(`notes/${noteId}/revision`);
toastService.showMessage(t("entrypoints.note-revision-created"));
}
}

View File

@@ -0,0 +1,8 @@
import type { MenuCommandItem } from "../menus/context_menu.js";
import type { CommandNames } from "./app_context.js";
type ListenerReturnType = void | Promise<void>;
export interface SelectMenuItemEventListener<T extends CommandNames> {
selectMenuItemHandler(item: MenuCommandItem<T>): ListenerReturnType;
}

View File

@@ -0,0 +1,82 @@
import appContext, { type EventData } from "./app_context.js";
import noteCreateService from "../services/note_create.js";
import treeService from "../services/tree.js";
import hoistedNoteService from "../services/hoisted_note.js";
import Component from "./component.js";
/**
* This class contains command executors which logically belong to the NoteTree widget, but for better user experience,
* the keyboard shortcuts must be active on the whole screen and not just on the widget itself, so the executors
* must be at the root of the component tree.
*/
export default class MainTreeExecutors extends Component {
/**
* On mobile it will be `undefined`.
*/
get tree() {
return appContext.noteTreeWidget;
}
async cloneNotesToCommand({ selectedOrActiveNoteIds }: EventData<"cloneNotesTo">) {
if (!selectedOrActiveNoteIds && this.tree) {
selectedOrActiveNoteIds = this.tree.getSelectedOrActiveNodes().map((node) => node.data.noteId);
}
if (!selectedOrActiveNoteIds) {
return;
}
this.triggerCommand("cloneNoteIdsTo", { noteIds: selectedOrActiveNoteIds });
}
async moveNotesToCommand({ selectedOrActiveBranchIds }: EventData<"moveNotesTo">) {
if (!selectedOrActiveBranchIds && this.tree) {
selectedOrActiveBranchIds = this.tree.getSelectedOrActiveNodes().map((node) => node.data.branchId);
}
if (!selectedOrActiveBranchIds) {
return;
}
this.triggerCommand("moveBranchIdsTo", { branchIds: selectedOrActiveBranchIds });
}
async createNoteIntoCommand() {
const activeNoteContext = appContext.tabManager.getActiveContext();
if (!activeNoteContext || !activeNoteContext.notePath || !activeNoteContext.note) {
return;
}
await noteCreateService.createNote(activeNoteContext.notePath, {
isProtected: activeNoteContext.note.isProtected,
saveSelection: false
});
}
async createNoteAfterCommand() {
if (!this.tree) {
return;
}
const node = this.tree.getActiveNode();
if (!node) {
return;
}
const parentNotePath = treeService.getNotePath(node.getParent());
const isProtected = treeService.getParentProtectedStatus(node);
if (node.data.noteId === "root" || node.data.noteId === hoistedNoteService.getHoistedNoteId()) {
return;
}
await noteCreateService.createNote(parentNotePath, {
target: "after",
targetBranchId: node.data.branchId,
isProtected: isProtected,
saveSelection: false
});
}
}

View File

@@ -0,0 +1,15 @@
import Component from "./component.js";
import type { CommandListener, CommandListenerData } from "./app_context.js";
export type Screen = "detail" | "tree";
export default class MobileScreenSwitcherExecutor extends Component implements CommandListener<"setActiveScreen"> {
private activeScreen?: Screen;
setActiveScreenCommand({ screen }: CommandListenerData<"setActiveScreen">) {
if (screen !== this.activeScreen) {
this.activeScreen = screen;
this.triggerEvent("activeScreenChanged", { activeScreen: screen });
}
}
}

View File

@@ -0,0 +1,416 @@
import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import utils from "../services/utils.js";
import appContext, { type EventData, type EventListener } from "./app_context.js";
import treeService from "../services/tree.js";
import Component from "./component.js";
import froca from "../services/froca.js";
import hoistedNoteService from "../services/hoisted_note.js";
import options from "../services/options.js";
import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.js";
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import type CodeMirror from "@triliumnext/codemirror";
import { closeActiveDialog } from "../services/dialog.js";
export interface SetNoteOpts {
triggerSwitchEvent?: unknown;
viewScope?: ViewScope;
}
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
ntxId: string | null;
hoistedNoteId: string;
mainNtxId: string | null;
notePath?: string | null;
noteId?: string | null;
parentNoteId?: string | null;
viewScope?: ViewScope;
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
super();
this.ntxId = ntxId || NoteContext.generateNtxId();
this.hoistedNoteId = hoistedNoteId;
this.mainNtxId = mainNtxId;
this.resetViewScope();
}
static generateNtxId() {
return utils.randomString(6);
}
setEmpty() {
this.notePath = null;
this.noteId = null;
this.parentNoteId = null;
// hoisted note is kept intentionally
this.triggerEvent("noteSwitched", {
noteContext: this,
notePath: this.notePath
});
this.resetViewScope();
}
isEmpty() {
return !this.noteId;
}
async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) {
opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true;
opts.viewScope = opts.viewScope || {};
opts.viewScope.viewMode = opts.viewScope.viewMode || "default";
if (!inputNotePath) {
return;
}
const resolvedNotePath = await this.getResolvedNotePath(inputNotePath);
if (!resolvedNotePath) {
return;
}
if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) {
return;
}
await this.triggerEvent("beforeNoteSwitch", { noteContext: this });
closeActiveDialog();
this.notePath = resolvedNotePath;
this.viewScope = opts.viewScope;
({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));
this.saveToRecentNotes(resolvedNotePath);
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
if (opts.triggerSwitchEvent) {
await this.triggerEvent("noteSwitched", {
noteContext: this,
notePath: this.notePath
});
}
await this.setHoistedNoteIfNeeded();
if (utils.isMobile()) {
this.triggerCommand("setActiveScreen", { screen: "detail" });
}
}
async setHoistedNoteIfNeeded() {
if (this.hoistedNoteId === "root" && this.notePath?.startsWith("root/_hidden") && !this.note?.isLabelTruthy("keepCurrentHoisting")) {
// hidden subtree displays only when hoisted, so it doesn't make sense to keep root as hoisted note
let hoistedNoteId = "_hidden";
if (this.note?.isLaunchBarConfig()) {
hoistedNoteId = "_lbRoot";
} else if (this.note?.isOptions()) {
hoistedNoteId = "_options";
}
await this.setHoistedNoteId(hoistedNoteId);
}
}
getSubContexts() {
return appContext.tabManager.noteContexts.filter((nc) => nc.ntxId === this.ntxId || nc.mainNtxId === this.ntxId);
}
/**
* A main context represents a tab and also the first split. Further splits are the children contexts of the main context.
* Imagine you have a tab with 3 splits, each showing notes A, B, C (in this order).
* In such a scenario, A context is the main context (also representing the tab as a whole), and B, C are the children
* of context A.
*
* @returns {boolean} true if the context is main (= tab)
*/
isMainContext() {
// if null, then this is a main context
return !this.mainNtxId;
}
/**
* See docs for isMainContext() for better explanation.
*
* @returns {NoteContext}
*/
getMainContext() {
if (this.mainNtxId) {
try {
return appContext.tabManager.getNoteContextById(this.mainNtxId);
} catch (e) {
this.mainNtxId = null;
return this;
}
} else {
return this;
}
}
saveToRecentNotes(resolvedNotePath: string) {
if (options.is("databaseReadonly")) {
return;
}
setTimeout(async () => {
// we include the note in the recent list only if the user stayed on the note at least 5 seconds
if (resolvedNotePath && resolvedNotePath === this.notePath) {
await server.post("recent-notes", {
noteId: this.note?.noteId,
notePath: this.notePath
});
utils.reloadTray();
}
}, 5000);
}
async getResolvedNotePath(inputNotePath: string) {
const resolvedNotePath = await treeService.resolveNotePath(inputNotePath, this.hoistedNoteId);
if (!resolvedNotePath) {
logError(`Cannot resolve note path ${inputNotePath}`);
return;
}
if ((await hoistedNoteService.checkNoteAccess(resolvedNotePath, this)) === false) {
return; // note is outside of hoisted subtree and user chose not to unhoist
}
return resolvedNotePath;
}
get note(): FNote | null {
if (!this.noteId || !(this.noteId in froca.notes)) {
return null;
}
return froca.notes[this.noteId];
}
/** @returns {string[]} */
get notePathArray() {
return this.notePath ? this.notePath.split("/") : [];
}
isActive() {
return appContext.tabManager.activeNtxId === this.ntxId;
}
getPojoState() {
if (this.hoistedNoteId !== "root") {
// keeping empty hoisted tab is esp. important for mobile (e.g. opened launcher config)
if (!this.notePath && this.getSubContexts().length === 0) {
return null;
}
}
return {
ntxId: this.ntxId,
mainNtxId: this.mainNtxId,
notePath: this.notePath,
hoistedNoteId: this.hoistedNoteId,
active: this.isActive(),
viewScope: this.viewScope
};
}
async unhoist() {
await this.setHoistedNoteId("root");
}
async setHoistedNoteId(noteIdToHoist: string) {
if (this.hoistedNoteId === noteIdToHoist) {
return;
}
this.hoistedNoteId = noteIdToHoist;
if (!this.notePathArray?.includes(noteIdToHoist)) {
await this.setNote(noteIdToHoist);
}
await this.triggerEvent("hoistedNoteChanged", {
noteId: noteIdToHoist,
ntxId: this.ntxId
});
}
/** @returns {Promise<boolean>} */
async isReadOnly() {
if (this?.viewScope?.readOnlyTemporarilyDisabled) {
return false;
}
// "readOnly" is a state valid only for text/code notes
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
return false;
}
if (options.is("databaseReadonly")) {
return true;
}
if (this.note.isLabelTruthy("readOnly")) {
return true;
}
if (this.viewScope?.viewMode === "source") {
return true;
}
// Store the initial decision about read-only status in the viewScope
// This will be "remembered" until the viewScope is refreshed
if (!this.viewScope) {
this.resetViewScope();
}
const viewScope = this.viewScope!;
if (viewScope.isReadOnly === undefined) {
const blob = await this.note.getBlob();
if (!blob) {
viewScope.isReadOnly = false;
return false;
}
const sizeLimit = this.note.type === "text"
? options.getInt("autoReadonlySizeText")
: options.getInt("autoReadonlySizeCode");
viewScope.isReadOnly = Boolean(sizeLimit &&
blob.contentLength > sizeLimit &&
!this.note.isLabelTruthy("autoReadOnlyDisabled"));
}
// Return the cached decision, which won't change until viewScope is reset
return viewScope.isReadOnly || false;
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.noteId && loadResults.isNoteReloaded(this.noteId)) {
const noteRow = loadResults.getEntityRow("notes", this.noteId);
if (noteRow.isDeleted) {
this.noteId = null;
this.notePath = null;
this.triggerEvent("noteSwitched", {
noteContext: this,
notePath: this.notePath
});
}
}
}
hasNoteList() {
return (
this.note &&
["default", "contextual-help"].includes(this.viewScope?.viewMode ?? "") &&
(this.note.hasChildren() || this.note.getLabelValue("viewType") === "calendar") &&
["book", "text", "code"].includes(this.note.type) &&
this.note.mime !== "text/x-sqlite;schema=trilium" &&
!this.note.isLabelTruthy("hideChildrenOverview")
);
}
async getTextEditor(callback?: GetTextEditorCallback) {
return this.timeout<CKTextEditor>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithTextEditor", {
callback,
resolve,
ntxId: this.ntxId
})
)
);
}
async getCodeEditor() {
return this.timeout(
new Promise<CodeMirror>((resolve) =>
appContext.triggerCommand("executeWithCodeEditor", {
resolve,
ntxId: this.ntxId
})
)
);
}
/**
* Returns a promise which will retrieve the JQuery element of the content of this note context.
*
* Do note that retrieving the content element needs to be handled by the type widget, which is the one which
* provides the content element by listening to the `executeWithContentElement` event. Not all note types support
* this.
*
* If no content could be determined `null` is returned instead.
*/
async getContentElement() {
return this.timeout<JQuery<HTMLElement>>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithContentElement", {
resolve,
ntxId: this.ntxId
})
)
);
}
async getTypeWidget() {
return this.timeout(
new Promise<TypeWidget | null>((resolve) =>
appContext.triggerCommand("executeWithTypeWidget", {
resolve,
ntxId: this.ntxId
})
)
);
}
timeout<T>(promise: Promise<T | null>) {
return Promise.race([promise, new Promise((res) => setTimeout(() => res(null), 200))]) as Promise<T>;
}
resetViewScope() {
// view scope contains data specific to one note context and one "view".
// it is used to e.g., make read-only note temporarily editable or to hide TOC
// this is reset after navigating to a different note
this.viewScope = {};
}
async getNavigationTitle() {
if (!this.note) {
return null;
}
const { note, viewScope } = this;
const isNormalView = (viewScope?.viewMode === "default" || viewScope?.viewMode === "contextual-help");
let title = (isNormalView ? note.title : `${note.title}: ${viewScope?.viewMode}`);
if (viewScope?.attachmentId) {
// assuming the attachment has been already loaded
const attachment = await note.getAttachmentById(viewScope.attachmentId);
if (attachment) {
title += `: ${attachment.title}`;
}
}
return title;
}
}
export default NoteContext;

View File

@@ -0,0 +1,263 @@
import Component from "./component.js";
import appContext, { type CommandData, type CommandListenerData } from "./app_context.js";
import dateNoteService from "../services/date_notes.js";
import treeService from "../services/tree.js";
import openService from "../services/open.js";
import protectedSessionService from "../services/protected_session.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import utils from "../services/utils.js";
import LlmChatPanel from "../widgets/llm_chat_panel.js";
import toastService from "../services/toast.js";
import noteCreateService from "../services/note_create.js";
export default class RootCommandExecutor extends Component {
editReadOnlyNoteCommand() {
const noteContext = appContext.tabManager.getActiveContext();
if (noteContext?.viewScope) {
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
}
}
async showSQLConsoleCommand() {
const sqlConsoleNote = await dateNoteService.createSqlConsole();
if (!sqlConsoleNote) {
return;
}
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(sqlConsoleNote.noteId, { activate: true });
appContext.triggerEvent("focusOnDetail", { ntxId: noteContext.ntxId });
}
async searchNotesCommand({ searchString, ancestorNoteId }: CommandListenerData<"searchNotes">) {
const searchNote = await dateNoteService.createSearchNote({ searchString, ancestorNoteId });
if (!searchNote) {
return;
}
// force immediate search
await froca.loadSearchNote(searchNote.noteId);
const noteContext = await appContext.tabManager.openTabWithNoteWithHoisting(searchNote.noteId, {
activate: true
});
appContext.triggerCommand("focusOnSearchDefinition", { ntxId: noteContext.ntxId });
}
async searchInSubtreeCommand({ notePath }: CommandListenerData<"searchInSubtree">) {
const noteId = treeService.getNoteIdFromUrl(notePath);
this.searchNotesCommand({ ancestorNoteId: noteId });
}
openNoteExternallyCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
const mime = appContext.tabManager.getActiveContextNoteMime();
if (noteId) {
openService.openNoteExternally(noteId, mime || "");
}
}
openNoteCustomCommand() {
const noteId = appContext.tabManager.getActiveContextNoteId();
const mime = appContext.tabManager.getActiveContextNoteMime();
if (noteId) {
openService.openNoteCustom(noteId, mime || "");
}
}
enterProtectedSessionCommand() {
protectedSessionService.enterProtectedSession();
}
leaveProtectedSessionCommand() {
protectedSessionService.leaveProtectedSession();
}
hideLeftPaneCommand() {
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: false });
}
showLeftPaneCommand() {
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: true });
}
toggleLeftPaneCommand() {
appContext.triggerEvent("setLeftPaneVisibility", { leftPaneVisible: null });
}
async showBackendLogCommand() {
await appContext.tabManager.openTabWithNoteWithHoisting("_backendLog", { activate: true });
}
async showHelpCommand() {
await this.showAndHoistSubtree("_help");
}
async showLaunchBarSubtreeCommand() {
const rootNote = utils.isMobile() ? "_lbMobileRoot" : "_lbRoot";
await this.showAndHoistSubtree(rootNote);
this.showLeftPaneCommand();
}
async showShareSubtreeCommand() {
await this.showAndHoistSubtree("_share");
}
async showHiddenSubtreeCommand() {
await this.showAndHoistSubtree("_hidden");
}
async showOptionsCommand({ section }: CommandListenerData<"showOptions">) {
await appContext.tabManager.openContextWithNote(section || "_options", {
activate: true,
hoistedNoteId: "_options"
});
}
async showSQLConsoleHistoryCommand() {
await this.showAndHoistSubtree("_sqlConsole");
}
async showSearchHistoryCommand() {
await this.showAndHoistSubtree("_search");
}
async showAndHoistSubtree(subtreeNoteId: string) {
await appContext.tabManager.openContextWithNote(subtreeNoteId, {
activate: true,
hoistedNoteId: subtreeNoteId
});
}
async showNoteSourceCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
activate: true,
viewScope: {
viewMode: "source"
}
});
}
}
async showAttachmentsCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
activate: true,
viewScope: {
viewMode: "attachments"
}
});
}
}
async showAttachmentDetailCommand() {
const notePath = appContext.tabManager.getActiveContextNotePath();
if (notePath) {
await appContext.tabManager.openTabWithNoteWithHoisting(notePath, {
activate: true,
viewScope: {
viewMode: "attachments"
}
});
}
}
toggleTrayCommand() {
if (!utils.isElectron()) return;
const { BrowserWindow } = utils.dynamicRequire("@electron/remote");
const windows = BrowserWindow.getAllWindows() as Electron.BaseWindow[];
const isVisible = windows.every((w) => w.isVisible());
const action = isVisible ? "hide" : "show";
for (const window of windows) window[action]();
}
toggleZenModeCommand() {
const $body = $("body");
$body.toggleClass("zen");
const isEnabled = $body.hasClass("zen");
appContext.triggerEvent("zenModeChanged", { isEnabled });
}
firstTabCommand() {
this.#goToTab(1);
}
secondTabCommand() {
this.#goToTab(2);
}
thirdTabCommand() {
this.#goToTab(3);
}
fourthTabCommand() {
this.#goToTab(4);
}
fifthTabCommand() {
this.#goToTab(5);
}
sixthTabCommand() {
this.#goToTab(6);
}
seventhTabCommand() {
this.#goToTab(7);
}
eigthTabCommand() {
this.#goToTab(8);
}
ninthTabCommand() {
this.#goToTab(9);
}
lastTabCommand() {
this.#goToTab(Number.POSITIVE_INFINITY);
}
#goToTab(tabNumber: number) {
const mainNoteContexts = appContext.tabManager.getMainNoteContexts();
const index = tabNumber === Number.POSITIVE_INFINITY ? mainNoteContexts.length - 1 : tabNumber - 1;
const tab = mainNoteContexts[index];
if (tab) {
appContext.tabManager.activateNoteContext(tab.ntxId);
}
}
async createAiChatCommand() {
try {
// Create a new AI Chat note at the root level
const rootNoteId = "root";
const result = await noteCreateService.createNote(rootNoteId, {
title: "New AI Chat",
type: "aiChat",
content: JSON.stringify({
messages: [],
title: "New AI Chat"
})
});
if (!result.note) {
toastService.showError("Failed to create AI Chat note");
return;
}
await appContext.tabManager.openTabWithNoteWithHoisting(result.note.noteId, {
activate: true
});
toastService.showMessage("Created new AI Chat note");
}
catch (e) {
console.error("Error creating AI Chat note:", e);
toastService.showError("Failed to create AI Chat note: " + (e as Error).message);
}
}
}

View File

@@ -0,0 +1,44 @@
import appContext, { type EventData, type EventListener } from "./app_context.js";
import shortcutService from "../services/shortcuts.js";
import server from "../services/server.js";
import Component from "./component.js";
import froca from "../services/froca.js";
import type { AttributeRow } from "../services/load_results.js";
export default class ShortcutComponent extends Component implements EventListener<"entitiesReloaded"> {
constructor() {
super();
server.get<AttributeRow[]>("keyboard-shortcuts-for-notes").then((shortcutAttributes) => {
for (const attr of shortcutAttributes) {
this.bindNoteShortcutHandler(attr);
}
});
}
bindNoteShortcutHandler(labelOrRow: AttributeRow) {
const handler = () => appContext.tabManager.getActiveContext()?.setNote(labelOrRow.noteId);
const namespace = labelOrRow.attributeId;
if (labelOrRow.isDeleted) {
// only applicable if row
if (namespace) {
shortcutService.removeGlobalShortcut(namespace);
}
} else if (labelOrRow.value) {
shortcutService.bindGlobalShortcut(labelOrRow.value, handler, namespace);
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
for (const attr of loadResults.getAttributeRows()) {
if (attr.type === "label" && attr.name === "keyboardShortcut" && attr.noteId) {
const note = await froca.getNote(attr.noteId);
// launcher shortcuts are handled specifically
if (note && attr && note.type !== "launcher") {
this.bindNoteShortcutHandler(attr);
}
}
}
}
}

View File

@@ -0,0 +1,26 @@
import server from "../services/server";
import Component from "./component";
// TODO: Deduplicate.
interface CpuArchResponse {
isCpuArchMismatch: boolean;
}
export class StartupChecks extends Component {
constructor() {
super();
this.checkCpuArchMismatch();
}
async checkCpuArchMismatch() {
try {
const response = await server.get("system-checks") as CpuArchResponse;
if (response.isCpuArchMismatch) {
this.triggerCommand("showCpuArchWarning", {});
}
} catch (error) {
console.warn("Could not check CPU arch status:", error);
}
}
}

View File

@@ -0,0 +1,711 @@
import Component from "./component.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import treeService from "../services/tree.js";
import NoteContext from "./note_context.js";
import appContext from "./app_context.js";
import Mutex from "../utils/mutex.js";
import linkService from "../services/link.js";
import type { EventData } from "./app_context.js";
import type FNote from "../entities/fnote.js";
interface TabState {
contexts: NoteContext[];
position: number;
}
interface NoteContextState {
ntxId: string;
mainNtxId: string | null;
notePath: string | null;
hoistedNoteId: string;
active: boolean;
viewScope: Record<string, any>;
}
export default class TabManager extends Component {
public children: NoteContext[];
public mutex: Mutex;
public activeNtxId: string | null;
public recentlyClosedTabs: TabState[];
public tabsUpdate: SpacedUpdate;
constructor() {
super();
this.children = [];
this.mutex = new Mutex();
this.activeNtxId = null;
this.recentlyClosedTabs = [];
this.tabsUpdate = new SpacedUpdate(async () => {
if (!appContext.isMainWindow) {
return;
}
if (options.is("databaseReadonly")) {
return;
}
const openNoteContexts = this.noteContexts
.map((nc) => nc.getPojoState())
.filter((t) => !!t);
await server.put("options", {
openNoteContexts: JSON.stringify(openNoteContexts)
});
});
appContext.addBeforeUnloadListener(this);
}
get noteContexts(): NoteContext[] {
return this.children;
}
get mainNoteContexts(): NoteContext[] {
return this.noteContexts.filter((nc) => !nc.mainNtxId);
}
async loadTabs() {
try {
const noteContextsToOpen = (appContext.isMainWindow && options.getJson("openNoteContexts")) || [];
// preload all notes at once
await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) =>
[treeService.getNoteIdFromUrl(tab.notePath), tab.hoistedNoteId])], true);
const filteredNoteContexts = noteContextsToOpen.filter((openTab: NoteContextState) => {
const noteId = treeService.getNoteIdFromUrl(openTab.notePath);
if (!noteId || !(noteId in froca.notes)) {
// note doesn't exist so don't try to open tab for it
return false;
}
if (!(openTab.hoistedNoteId in froca.notes)) {
openTab.hoistedNoteId = "root";
}
return true;
});
// resolve before opened tabs can change this
const parsedFromUrl = linkService.parseNavigationStateFromUrl(window.location.href);
if (filteredNoteContexts.length === 0) {
parsedFromUrl.ntxId = parsedFromUrl.ntxId || NoteContext.generateNtxId(); // generate already here, so that we later know which one to activate
filteredNoteContexts.push({
notePath: parsedFromUrl.notePath || "root",
ntxId: parsedFromUrl.ntxId,
active: true,
hoistedNoteId: parsedFromUrl.hoistedNoteId || "root",
viewScope: parsedFromUrl.viewScope || {}
});
} else if (!filteredNoteContexts.find((tab: NoteContextState) => tab.active)) {
filteredNoteContexts[0].active = true;
}
await this.tabsUpdate.allowUpdateWithoutChange(async () => {
for (const tab of filteredNoteContexts) {
await this.openContextWithNote(tab.notePath, {
activate: tab.active,
ntxId: tab.ntxId,
mainNtxId: tab.mainNtxId,
hoistedNoteId: tab.hoistedNoteId,
viewScope: tab.viewScope
});
}
});
// if there's a notePath in the URL, make sure it's open and active
// (useful, for e.g., opening clipped notes from clipper or opening link in an extra window)
if (parsedFromUrl.notePath) {
await appContext.tabManager.switchToNoteContext(
parsedFromUrl.ntxId,
parsedFromUrl.notePath,
parsedFromUrl.viewScope,
parsedFromUrl.hoistedNoteId
);
} else if (parsedFromUrl.searchString) {
await appContext.triggerCommand("searchNotes", {
searchString: parsedFromUrl.searchString
});
}
} catch (e: unknown) {
if (e instanceof Error) {
logError(`Loading note contexts '${options.get("openNoteContexts")}' failed: ${e.message} ${e.stack}`);
} else {
logError(`Loading note contexts '${options.get("openNoteContexts")}' failed: ${String(e)}`);
}
// try to recover
await this.openEmptyTab();
}
}
noteSwitchedEvent({ noteContext }: EventData<"noteSwitched">) {
if (noteContext.isActive()) {
this.setCurrentNavigationStateToHash();
}
this.tabsUpdate.scheduleUpdate();
}
setCurrentNavigationStateToHash() {
const calculatedHash = this.calculateHash();
// update if it's the first history entry or there has been a change
if (window.history.length === 0 || calculatedHash !== window.location?.hash) {
// using pushState instead of directly modifying document.location because it does not trigger hashchange
window.history.pushState(null, "", calculatedHash);
}
const activeNoteContext = this.getActiveContext();
this.updateDocumentTitle(activeNoteContext);
this.triggerEvent("activeNoteChanged", {}); // trigger this even in on popstate event
}
calculateHash(): string {
const activeNoteContext = this.getActiveContext();
if (!activeNoteContext) {
return "";
}
return linkService.calculateHash({
notePath: activeNoteContext.notePath,
ntxId: activeNoteContext.ntxId,
hoistedNoteId: activeNoteContext.hoistedNoteId,
viewScope: activeNoteContext.viewScope
});
}
getNoteContexts(): NoteContext[] {
return this.noteContexts;
}
getMainNoteContexts(): NoteContext[] {
return this.noteContexts.filter((nc) => nc.isMainContext());
}
getNoteContextById(ntxId: string | null): NoteContext {
const noteContext = this.noteContexts.find((nc) => nc.ntxId === ntxId);
if (!noteContext) {
throw new Error(`Cannot find noteContext id='${ntxId}'`);
}
return noteContext;
}
getActiveContext(): NoteContext | null {
return this.activeNtxId ? this.getNoteContextById(this.activeNtxId) : null;
}
getActiveMainContext(): NoteContext | null {
return this.activeNtxId ? this.getNoteContextById(this.activeNtxId).getMainContext() : null;
}
getActiveContextNotePath(): string | null {
const activeContext = this.getActiveContext();
return activeContext?.notePath ?? null;
}
getActiveContextNote(): FNote | null {
const activeContext = this.getActiveContext();
return activeContext ? activeContext.note : null;
}
getActiveContextNoteId(): string | null {
const activeNote = this.getActiveContextNote();
return activeNote ? activeNote.noteId : null;
}
getActiveContextNoteType(): string | null {
const activeNote = this.getActiveContextNote();
return activeNote ? activeNote.type : null;
}
getActiveContextNoteMime(): string | null {
const activeNote = this.getActiveContextNote();
return activeNote ? activeNote.mime : null;
}
async switchToNoteContext(
ntxId: string | null,
notePath: string,
viewScope: Record<string, any> = {},
hoistedNoteId: string | null = null
) {
const noteContext = this.noteContexts.find((nc) => nc.ntxId === ntxId) ||
await this.openEmptyTab();
await this.activateNoteContext(noteContext.ntxId);
if (hoistedNoteId) {
await noteContext.setHoistedNoteId(hoistedNoteId);
}
if (notePath) {
await noteContext.setNote(notePath, { viewScope });
}
}
async openAndActivateEmptyTab() {
const noteContext = await this.openEmptyTab();
await this.activateNoteContext(noteContext.ntxId);
noteContext.setEmpty();
}
async openEmptyTab(
ntxId: string | null = null,
hoistedNoteId: string = "root",
mainNtxId: string | null = null
): Promise<NoteContext> {
const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId);
const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId);
if (existingNoteContext) {
await existingNoteContext.setHoistedNoteId(hoistedNoteId);
return existingNoteContext;
}
this.child(noteContext);
await this.triggerEvent("newNoteContextCreated", { noteContext });
return noteContext;
}
async openInNewTab(targetNoteId: string, hoistedNoteId: string | null = null, activate: boolean = false) {
const noteContext = await this.openEmptyTab(null, hoistedNoteId || this.getActiveContext()?.hoistedNoteId);
await noteContext.setNote(targetNoteId);
if (activate && noteContext.notePath) {
this.activateNoteContext(noteContext.ntxId, false);
await this.triggerEvent("noteSwitchedAndActivated", {
noteContext,
notePath: noteContext.notePath
});
}
}
async openInSameTab(targetNoteId: string, hoistedNoteId: string | null = null) {
const activeContext = this.getActiveContext();
if (!activeContext) return;
await activeContext.setHoistedNoteId(hoistedNoteId || activeContext.hoistedNoteId);
await activeContext.setNote(targetNoteId);
}
async openTabWithNoteWithHoisting(
notePath: string,
opts: {
activate?: boolean | null;
ntxId?: string | null;
mainNtxId?: string | null;
hoistedNoteId?: string | null;
viewScope?: Record<string, any> | null;
} = {}
): Promise<NoteContext> {
const noteContext = this.getActiveContext();
let hoistedNoteId = "root";
if (noteContext) {
const resolvedNotePath = await treeService.resolveNotePath(notePath, noteContext.hoistedNoteId);
if (resolvedNotePath?.includes(noteContext.hoistedNoteId) || resolvedNotePath?.includes("_hidden")) {
hoistedNoteId = noteContext.hoistedNoteId;
}
}
opts.hoistedNoteId = hoistedNoteId;
return this.openContextWithNote(notePath, opts);
}
async openContextWithNote(
notePath: string | null,
opts: {
activate?: boolean | null;
ntxId?: string | null;
mainNtxId?: string | null;
hoistedNoteId?: string | null;
viewScope?: Record<string, any> | null;
} = {}
): Promise<NoteContext> {
const activate = !!opts.activate;
const ntxId = opts.ntxId || null;
const mainNtxId = opts.mainNtxId || null;
const hoistedNoteId = opts.hoistedNoteId || "root";
const viewScope = opts.viewScope || { viewMode: "default" };
const noteContext = await this.openEmptyTab(ntxId, hoistedNoteId, mainNtxId);
if (notePath) {
await noteContext.setNote(notePath, {
// if activate is false, then send normal noteSwitched event
triggerSwitchEvent: !activate,
viewScope: viewScope
});
}
if (activate && noteContext.notePath) {
this.activateNoteContext(noteContext.ntxId, false);
await this.triggerEvent("noteSwitchedAndActivated", {
noteContext,
notePath: noteContext.notePath // resolved note path
});
}
return noteContext;
}
async activateOrOpenNote(noteId: string) {
for (const noteContext of this.getNoteContexts()) {
if (noteContext.note && noteContext.note.noteId === noteId) {
this.activateNoteContext(noteContext.ntxId);
return;
}
}
// if no tab with this note has been found we'll create new tab
await this.openContextWithNote(noteId, { activate: true });
}
async activateNoteContext(ntxId: string | null, triggerEvent: boolean = true) {
if (!ntxId) {
logError("activateNoteContext: ntxId is null");
return;
}
if (ntxId === this.activeNtxId) {
return;
}
this.activeNtxId = ntxId;
if (triggerEvent) {
await this.triggerEvent("activeContextChanged", {
noteContext: this.getNoteContextById(ntxId)
});
}
this.tabsUpdate.scheduleUpdate();
this.setCurrentNavigationStateToHash();
}
async removeNoteContext(ntxId: string | null): Promise<boolean> {
// removing note context is an async process which can take some time, if users presses CTRL-W quickly, two
// close events could interleave which would then lead to attempting to activate already removed context.
return await this.mutex.runExclusively(async (): Promise<boolean> => {
let noteContextToRemove;
try {
noteContextToRemove = this.getNoteContextById(ntxId);
} catch {
// note context not found
return false;
}
if (noteContextToRemove.isMainContext()) {
const mainNoteContexts = this.getNoteContexts().filter((nc) => nc.isMainContext());
if (mainNoteContexts.length === 1) {
if (noteContextToRemove.isEmpty()) {
// this is already the empty note context, no point in closing it and replacing with another
// empty tab
return false;
}
await this.openEmptyTab();
}
}
// close dangling autocompletes after closing the tab
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
const noteContextsToRemove = noteContextToRemove.getSubContexts();
const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId);
await this.triggerEvent("beforeNoteContextRemove", { ntxIds: ntxIdsToRemove.filter((id) => id !== null) });
if (!noteContextToRemove.isMainContext()) {
const siblings = noteContextToRemove.getMainContext().getSubContexts();
const idx = siblings.findIndex((nc) => nc.ntxId === noteContextToRemove.ntxId);
const contextToActivateIdx = idx === siblings.length - 1 ? idx - 1 : idx + 1;
const contextToActivate = siblings[contextToActivateIdx];
await this.activateNoteContext(contextToActivate.ntxId);
} else if (this.mainNoteContexts.length <= 1) {
await this.openAndActivateEmptyTab();
} else if (ntxIdsToRemove.includes(this.activeNtxId)) {
const idx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === noteContextToRemove.ntxId);
if (idx === this.mainNoteContexts.length - 1) {
await this.activatePreviousTabCommand();
} else {
await this.activateNextTabCommand();
}
}
this.removeNoteContexts(noteContextsToRemove);
return true;
});
}
removeNoteContexts(noteContextsToRemove: NoteContext[]) {
const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId);
const position = this.noteContexts.findIndex((nc) => ntxIdsToRemove.includes(nc.ntxId));
this.children = this.children.filter((nc) => !ntxIdsToRemove.includes(nc.ntxId));
this.addToRecentlyClosedTabs(noteContextsToRemove, position);
this.triggerEvent("noteContextRemoved", { ntxIds: ntxIdsToRemove.filter((id) => id !== null) });
this.tabsUpdate.scheduleUpdate();
}
addToRecentlyClosedTabs(noteContexts: NoteContext[], position: number) {
if (noteContexts.length === 1 && noteContexts[0].isEmpty()) {
return;
}
this.recentlyClosedTabs.push({ contexts: noteContexts, position: position });
}
tabReorderEvent({ ntxIdsInOrder }: { ntxIdsInOrder: string[] }) {
const order: Record<string, number> = {};
let i = 0;
for (const ntxId of ntxIdsInOrder) {
for (const noteContext of this.getNoteContextById(ntxId).getSubContexts()) {
if (noteContext.ntxId) {
order[noteContext.ntxId] = i++;
}
}
}
this.children.sort((a, b) => {
if (!a.ntxId || !b.ntxId) return 0;
return (order[a.ntxId] ?? 0) < (order[b.ntxId] ?? 0) ? -1 : 1;
});
this.tabsUpdate.scheduleUpdate();
}
noteContextReorderEvent({
ntxIdsInOrder,
oldMainNtxId,
newMainNtxId
}: {
ntxIdsInOrder: string[];
oldMainNtxId?: string;
newMainNtxId?: string;
}) {
const order = Object.fromEntries(ntxIdsInOrder.map((v, i) => [v, i]));
this.children.sort((a, b) => {
if (!a.ntxId || !b.ntxId) return 0;
return (order[a.ntxId] ?? 0) < (order[b.ntxId] ?? 0) ? -1 : 1;
});
if (oldMainNtxId && newMainNtxId) {
this.children.forEach((c) => {
if (c.ntxId === newMainNtxId) {
// new main context has null mainNtxId
c.mainNtxId = null;
} else if (c.ntxId === oldMainNtxId || c.mainNtxId === oldMainNtxId) {
// old main context or subcontexts all have the new mainNtxId
c.mainNtxId = newMainNtxId;
}
});
}
this.tabsUpdate.scheduleUpdate();
}
async activateNextTabCommand() {
const activeMainNtxId = this.getActiveMainContext()?.ntxId;
if (!activeMainNtxId) return;
const oldIdx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === activeMainNtxId);
const newActiveNtxId = this.mainNoteContexts[oldIdx === this.mainNoteContexts.length - 1 ? 0 : oldIdx + 1].ntxId;
await this.activateNoteContext(newActiveNtxId);
}
async activatePreviousTabCommand() {
const activeMainNtxId = this.getActiveMainContext()?.ntxId;
if (!activeMainNtxId) return;
const oldIdx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === activeMainNtxId);
const newActiveNtxId = this.mainNoteContexts[oldIdx === 0 ? this.mainNoteContexts.length - 1 : oldIdx - 1].ntxId;
await this.activateNoteContext(newActiveNtxId);
}
async closeActiveTabCommand() {
await this.removeNoteContext(this.activeNtxId);
}
beforeUnloadEvent(): boolean {
this.tabsUpdate.updateNowIfNecessary();
return true; // don't block closing the tab, this metadata is not that important
}
openNewTabCommand() {
this.openAndActivateEmptyTab();
}
async closeAllTabsCommand() {
for (const ntxIdToRemove of this.mainNoteContexts.map((nc) => nc.ntxId)) {
await this.removeNoteContext(ntxIdToRemove);
}
}
async closeOtherTabsCommand({ ntxId }: { ntxId: string }) {
for (const ntxIdToRemove of this.mainNoteContexts.map((nc) => nc.ntxId)) {
if (ntxIdToRemove !== ntxId) {
await this.removeNoteContext(ntxIdToRemove);
}
}
}
async closeRightTabsCommand({ ntxId }: { ntxId: string }) {
const ntxIds = this.mainNoteContexts.map((nc) => nc.ntxId);
const index = ntxIds.indexOf(ntxId);
if (index !== -1) {
const idsToRemove = ntxIds.slice(index + 1);
for (const ntxIdToRemove of idsToRemove) {
await this.removeNoteContext(ntxIdToRemove);
}
}
}
async closeTabCommand({ ntxId }: { ntxId: string }) {
await this.removeNoteContext(ntxId);
}
async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
const removed = await this.removeNoteContext(ntxId);
if (removed) {
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
}
}
async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) {
const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId);
this.triggerCommand("openInWindow", { notePath, hoistedNoteId });
}
async reopenLastTabCommand() {
const closeLastEmptyTab: NoteContext | undefined = await this.mutex.runExclusively(async () => {
let closeLastEmptyTab
if (this.recentlyClosedTabs.length === 0) {
return closeLastEmptyTab;
}
if (this.noteContexts.length === 1 && this.noteContexts[0].isEmpty()) {
// new empty tab is created after closing the last tab, this reverses the empty tab creation
closeLastEmptyTab = this.noteContexts[0];
}
const lastClosedTab = this.recentlyClosedTabs.pop();
if (!lastClosedTab) return closeLastEmptyTab;
const noteContexts = lastClosedTab.contexts;
for (const noteContext of noteContexts) {
this.child(noteContext);
await this.triggerEvent("newNoteContextCreated", { noteContext });
}
// restore last position of contexts stored in tab manager
const ntxsInOrder = [
...this.noteContexts.slice(0, lastClosedTab.position),
...this.noteContexts.slice(-noteContexts.length),
...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length)
];
this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) });
let mainNtx = noteContexts.find((nc) => nc.isMainContext());
if (mainNtx) {
// reopened a tab, need to reorder new tab widget in tab row
await this.triggerEvent("contextsReopened", {
mainNtxId: mainNtx.ntxId,
tabPosition: ntxsInOrder.filter((nc) => nc.isMainContext()).findIndex((nc) => nc.ntxId === mainNtx.ntxId)
});
} else {
// reopened a single split, need to reorder the pane widget in split note container
await this.triggerEvent("contextsReopened", {
mainNtxId: ntxsInOrder[lastClosedTab.position].ntxId,
// this is safe since lastClosedTab.position can never be 0 in this case
tabPosition: lastClosedTab.position - 1
});
}
const noteContextToActivate = noteContexts.length === 1 ? noteContexts[0] : noteContexts.find((nc) => nc.isMainContext());
if (!noteContextToActivate) return closeLastEmptyTab;
await this.activateNoteContext(noteContextToActivate.ntxId);
await this.triggerEvent("noteSwitched", {
noteContext: noteContextToActivate,
notePath: noteContextToActivate.notePath
});
return closeLastEmptyTab;
});
if (closeLastEmptyTab) {
await this.removeNoteContext(closeLastEmptyTab.ntxId);
}
}
hoistedNoteChangedEvent() {
this.tabsUpdate.scheduleUpdate();
}
async updateDocumentTitle(activeNoteContext: NoteContext | null) {
if (!activeNoteContext) return;
const titleFragments = [
// it helps to navigate in history if note title is included in the title
await activeNoteContext.getNavigationTitle(),
"TriliumNext Notes"
].filter(Boolean);
document.title = titleFragments.join(" - ");
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
const activeContext = this.getActiveContext();
if (activeContext && loadResults.isNoteReloaded(activeContext.noteId)) {
await this.updateDocumentTitle(activeContext);
}
}
async frocaReloadedEvent() {
const activeContext = this.getActiveContext();
if (activeContext) {
await this.updateDocumentTitle(activeContext);
}
}
}

View File

@@ -0,0 +1,135 @@
import utils from "../services/utils.js";
import Component from "./component.js";
import appContext from "./app_context.js";
import type { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl, TouchBarSpacer } from "@electron/remote";
export type TouchBarItem = (TouchBarButton | TouchBarSpacer | TouchBarGroup | TouchBarSegmentedControl);
export function buildSelectedBackgroundColor(isSelected: boolean) {
return isSelected ? "#757575" : undefined;
}
export default class TouchBarComponent extends Component {
nativeImage: typeof import("electron").nativeImage;
remote: typeof import("@electron/remote");
lastFocusedComponent?: Component;
private $activeModal?: JQuery<HTMLElement>;
constructor() {
super();
this.nativeImage = utils.dynamicRequire("electron").nativeImage;
this.remote = utils.dynamicRequire("@electron/remote") as typeof import("@electron/remote");
this.$widget = $("<div>");
$(window).on("focusin", async (e) => {
const $target = $(e.target);
this.$activeModal = $target.closest(".modal-dialog");
const parentComponentEl = $target.closest(".component");
this.lastFocusedComponent = appContext.getComponentByEl(parentComponentEl[0]);
this.#refreshTouchBar();
});
}
buildIcon(name: string) {
const sourceImage = this.nativeImage.createFromNamedImage(name, [-1, 0, 1]);
const { width, height } = sourceImage.getSize();
const newImage = this.nativeImage.createEmpty();
newImage.addRepresentation({
scaleFactor: 1,
width: width / 2,
height: height / 2,
buffer: sourceImage.resize({ height: height / 2 }).toBitmap()
});
newImage.addRepresentation({
scaleFactor: 2,
width: width,
height: height,
buffer: sourceImage.toBitmap()
});
return newImage;
}
#refreshTouchBar() {
const { TouchBar } = this.remote;
const parentComponent = this.lastFocusedComponent;
let touchBar: Electron.CrossProcessExports.TouchBar | null = null;
if (this.$activeModal?.length) {
touchBar = this.#buildModalTouchBar();
} else if (parentComponent) {
const items = parentComponent.triggerCommand("buildTouchBar", {
TouchBar,
buildIcon: this.buildIcon.bind(this)
}) as unknown as TouchBarItem[];
touchBar = this.#buildTouchBar(items);
}
if (touchBar) {
this.remote.getCurrentWindow().setTouchBar(touchBar);
}
}
#buildModalTouchBar() {
const { TouchBar } = this.remote;
const { TouchBarButton, TouchBarLabel, TouchBarSpacer } = this.remote.TouchBar;
const items: TouchBarItem[] = [];
// Look for the modal title.
const $title = this.$activeModal?.find(".modal-title");
if ($title?.length) {
items.push(new TouchBarLabel({ label: $title.text() }))
}
items.push(new TouchBarSpacer({ size: "flexible" }));
// Look for buttons in the modal.
const $buttons = this.$activeModal?.find(".modal-footer button");
for (const button of $buttons ?? []) {
items.push(new TouchBarButton({
label: button.innerText,
click: () => button.click(),
enabled: !button.hasAttribute("disabled")
}));
}
items.push(new TouchBarSpacer({ size: "flexible" }));
return new TouchBar({ items });
}
#buildTouchBar(componentSpecificItems?: TouchBarItem[]) {
const { TouchBar } = this.remote;
const { TouchBarButton, TouchBarSpacer, TouchBarGroup, TouchBarSegmentedControl, TouchBarOtherItemsProxy } = this.remote.TouchBar;
// Disregard recursive calls or empty results.
if (!componentSpecificItems || "then" in componentSpecificItems) {
componentSpecificItems = [];
}
const items = [
new TouchBarButton({
icon: this.buildIcon("NSTouchBarComposeTemplate"),
click: () => this.triggerCommand("createNoteIntoInbox")
}),
new TouchBarSpacer({ size: "small" }),
...componentSpecificItems,
new TouchBarSpacer({ size: "flexible" }),
new TouchBarOtherItemsProxy(),
new TouchBarButton({
icon: this.buildIcon("NSTouchBarAddDetailTemplate"),
click: () => this.triggerCommand("jumpToNote")
})
].flat();
console.log("Update ", items);
return new TouchBar({
items
});
}
refreshTouchBarEvent() {
this.#refreshTouchBar();
}
}

View File

@@ -0,0 +1,68 @@
import options from "../services/options.js";
import Component from "./component.js";
import utils from "../services/utils.js";
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 2.0;
class ZoomComponent extends Component {
constructor() {
super();
if (utils.isElectron()) {
options.initializedPromise.then(() => {
const zoomFactor = options.getFloat("zoomFactor");
if (zoomFactor) {
this.setZoomFactor(zoomFactor);
}
});
window.addEventListener("wheel", (event) => {
if (event.ctrlKey) {
this.setZoomFactorAndSave(this.getCurrentZoom() - event.deltaY * 0.001);
}
});
}
}
setZoomFactor(zoomFactor: string | number) {
const parsedZoomFactor = typeof zoomFactor !== "number" ? parseFloat(zoomFactor) : zoomFactor;
const webFrame = utils.dynamicRequire("electron").webFrame;
webFrame.setZoomFactor(parsedZoomFactor);
}
async setZoomFactorAndSave(zoomFactor: number) {
if (zoomFactor >= MIN_ZOOM && zoomFactor <= MAX_ZOOM) {
zoomFactor = Math.round(zoomFactor * 10) / 10;
this.setZoomFactor(zoomFactor);
await options.save("zoomFactor", zoomFactor);
} else {
console.log(`Zoom factor ${zoomFactor} outside of the range, ignored.`);
}
}
getCurrentZoom() {
return utils.dynamicRequire("electron").webFrame.getZoomFactor();
}
zoomOutEvent() {
this.setZoomFactorAndSave(this.getCurrentZoom() - 0.1);
}
zoomInEvent() {
this.setZoomFactorAndSave(this.getCurrentZoom() + 0.1);
}
zoomResetEvent() {
this.setZoomFactorAndSave(1);
}
setZoomFactorAndSaveEvent({ zoomFactor }: { zoomFactor: number }) {
this.setZoomFactorAndSave(zoomFactor);
}
}
const zoomService = new ZoomComponent();
export default zoomService;

117
apps/client/src/desktop.ts Normal file
View File

@@ -0,0 +1,117 @@
import appContext from "./components/app_context.js";
import utils from "./services/utils.js";
import noteTooltipService from "./services/note_tooltip.js";
import bundleService from "./services/bundle.js";
import toastService from "./services/toast.js";
import noteAutocompleteService from "./services/note_autocomplete.js";
import electronContextMenu from "./menus/electron_context_menu.js";
import glob from "./services/glob.js";
import { t } from "./services/i18n.js";
import options from "./services/options.js";
import server from "./services/server.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "jquery-hotkeys";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();
bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
// A dynamic import is required for layouts since they initialize components which require translations.
const DesktopLayout = (await import("./layouts/desktop_layout.js")).default;
appContext.setLayout(new DesktopLayout(widgetBundles));
appContext.start().catch((e) => {
toastService.showPersistent({
title: t("toast.critical-error.title"),
icon: "alert",
message: t("toast.critical-error.message", { message: e.message })
});
console.error("Critical error occured", e);
});
});
glob.setupGlobs();
if (utils.isElectron()) {
initOnElectron();
}
noteTooltipService.setupGlobalTooltip();
noteAutocompleteService.init();
if (utils.isElectron()) {
electronContextMenu.setupContextMenu();
}
function initOnElectron() {
const electron: typeof Electron = utils.dynamicRequire("electron");
electron.ipcRenderer.on("globalShortcut", async (event, actionName) => appContext.triggerCommand(actionName));
electron.ipcRenderer.on("openInSameTab", async (event, noteId) => appContext.tabManager.openInSameTab(noteId));
const electronRemote: typeof ElectronRemote = utils.dynamicRequire("@electron/remote");
const currentWindow = electronRemote.getCurrentWindow();
const style = window.getComputedStyle(document.body);
initDarkOrLightMode(style);
initTransparencyEffects(style, currentWindow);
if (options.get("nativeTitleBarVisible") !== "true") {
initTitleBarButtons(style, currentWindow);
}
}
function initTitleBarButtons(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
if (window.glob.platform === "win32") {
const applyWindowsOverlay = () => {
const color = style.getPropertyValue("--native-titlebar-background");
const symbolColor = style.getPropertyValue("--native-titlebar-foreground");
if (color && symbolColor) {
currentWindow.setTitleBarOverlay({ color, symbolColor });
}
};
applyWindowsOverlay();
// Register for changes to the native title bar colors.
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyWindowsOverlay);
}
if (window.glob.platform === "darwin") {
const xOffset = parseInt(style.getPropertyValue("--native-titlebar-darwin-x-offset"), 10);
const yOffset = parseInt(style.getPropertyValue("--native-titlebar-darwin-y-offset"), 10);
currentWindow.setWindowButtonPosition({ x: xOffset, y: yOffset });
}
}
function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Electron.BrowserWindow) {
if (window.glob.platform === "win32") {
const material = style.getPropertyValue("--background-material");
// TriliumNextTODO: find a nicer way to make TypeScript happy unfortunately TS did not like Array.includes here
const bgMaterialOptions = ["auto", "none", "mica", "acrylic", "tabbed"] as const;
const foundBgMaterialOption = bgMaterialOptions.find((bgMaterialOption) => material === bgMaterialOption);
if (foundBgMaterialOption) {
currentWindow.setBackgroundMaterial(foundBgMaterialOption);
}
}
}
/**
* Informs Electron that we prefer a dark or light theme. Apart from changing prefers-color-scheme at CSS level which is a side effect,
* this fixes color issues with background effects or native title bars.
*
* @param style the root CSS element to read variables from.
*/
function initDarkOrLightMode(style: CSSStyleDeclaration) {
let themeSource: typeof nativeTheme.themeSource = "system";
const themeStyle = style.getPropertyValue("--theme-style");
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
themeSource = themeStyle;
}
const { nativeTheme } = utils.dynamicRequire("@electron/remote") as typeof ElectronRemote;
nativeTheme.themeSource = themeSource;
}

View File

@@ -0,0 +1,65 @@
import type { Froca } from "../services/froca-interface.js";
export interface FAttachmentRow {
attachmentId: string;
ownerId: string;
role: string;
mime: string;
title: string;
dateModified: string;
utcDateModified: string;
utcDateScheduledForErasureSince: string;
contentLength: number;
}
/**
* Attachment is a file directly tied into a note without
* being a hidden child.
*/
class FAttachment {
private froca: Froca;
attachmentId!: string;
ownerId!: string;
role!: string;
mime!: string;
title!: string;
isProtected!: boolean; // TODO: Is this used?
private dateModified!: string;
utcDateModified!: string;
utcDateScheduledForErasureSince!: string;
/**
* optionally added to the entity
*/
contentLength!: number;
constructor(froca: Froca, row: FAttachmentRow) {
/** @type {Froca} */
this.froca = froca;
this.update(row);
}
update(row: FAttachmentRow) {
this.attachmentId = row.attachmentId;
this.ownerId = row.ownerId;
this.role = row.role;
this.mime = row.mime;
this.title = row.title;
this.dateModified = row.dateModified;
this.utcDateModified = row.utcDateModified;
this.utcDateScheduledForErasureSince = row.utcDateScheduledForErasureSince;
this.contentLength = row.contentLength;
this.froca.attachments[this.attachmentId] = this;
}
getNote() {
return this.froca.notes[this.ownerId];
}
async getBlob() {
return await this.froca.getBlob("attachments", this.attachmentId);
}
}
export default FAttachment;

View File

@@ -0,0 +1,96 @@
import type { Froca } from "../services/froca-interface.js";
import promotedAttributeDefinitionParser from "../services/promoted_attribute_definition_parser.js";
/**
* There are currently only two types of attributes, labels or relations.
*/
export type AttributeType = "label" | "relation";
export interface FAttributeRow {
attributeId: string;
noteId: string;
type: AttributeType;
name: string;
value: string;
position: number;
isInheritable: boolean;
}
/**
* Attribute is an abstract concept which has two real uses - label (key - value pair)
* and relation (representing named relationship between source and target note)
*/
class FAttribute {
private froca: Froca;
attributeId!: string;
noteId!: string;
type!: AttributeType;
name!: string;
value!: string;
position!: number;
isInheritable!: boolean;
constructor(froca: Froca, row: FAttributeRow) {
this.froca = froca;
this.update(row);
}
update(row: FAttributeRow) {
this.attributeId = row.attributeId;
this.noteId = row.noteId;
this.type = row.type;
this.name = row.name;
this.value = row.value;
this.position = row.position;
this.isInheritable = !!row.isInheritable;
}
getNote() {
return this.froca.notes[this.noteId];
}
async getTargetNote() {
const targetNoteId = this.targetNoteId;
return await this.froca.getNote(targetNoteId, true);
}
get targetNoteId() {
// alias
if (this.type !== "relation") {
throw new Error(`Attribute ${this.attributeId} is not a relation`);
}
return this.value;
}
get isAutoLink() {
return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name);
}
get toString() {
return `FAttribute(attributeId=${this.attributeId}, type=${this.type}, name=${this.name}, value=${this.value})`;
}
isDefinition() {
return this.type === "label" && (this.name.startsWith("label:") || this.name.startsWith("relation:"));
}
getDefinition() {
return promotedAttributeDefinitionParser.parse(this.value);
}
isDefinitionFor(attr: FAttribute) {
return this.type === "label" && this.name === `${attr.type}:${attr.name}`;
}
get dto(): Omit<FAttribute, "froca"> {
const dto: any = Object.assign({}, this);
delete dto.froca;
return dto;
}
}
export default FAttribute;

View File

@@ -0,0 +1,45 @@
export interface FBlobRow {
blobId: string;
content: string;
contentLength: number;
dateModified: string;
utcDateModified: string;
}
export default class FBlob {
blobId: string;
/**
* can either contain the whole content (in e.g. string notes), only part (large text notes) or nothing at all (binary notes, images)
*/
content: string;
contentLength: number;
dateModified: string;
utcDateModified: string;
constructor(row: FBlobRow) {
this.blobId = row.blobId;
this.content = row.content;
this.contentLength = row.contentLength;
this.dateModified = row.dateModified;
this.utcDateModified = row.utcDateModified;
}
/**
* @throws Error in case of invalid JSON
*/
getJsonContent<T>(): T | null {
if (!this.content || !this.content.trim()) {
return null;
}
return JSON.parse(this.content);
}
getJsonContentSafely(): unknown | null {
try {
return this.getJsonContent();
} catch (e) {
return null;
}
}
}

View File

@@ -0,0 +1,79 @@
import type { Froca } from "../services/froca-interface.js";
export interface FBranchRow {
branchId: string;
noteId: string;
parentNoteId: string;
notePosition: number;
prefix?: string;
isExpanded?: boolean;
fromSearchNote: boolean;
isDeleted?: boolean;
}
/**
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple
* parents.
*/
class FBranch {
private froca: Froca;
/**
* primary key
*/
branchId!: string;
noteId!: string;
parentNoteId!: string;
notePosition!: number;
prefix?: string;
isExpanded?: boolean;
fromSearchNote!: boolean;
constructor(froca: Froca, row: FBranchRow) {
this.froca = froca;
this.update(row);
}
update(row: FBranchRow) {
/**
* primary key
*/
this.branchId = row.branchId;
this.noteId = row.noteId;
this.parentNoteId = row.parentNoteId;
this.notePosition = row.notePosition;
this.prefix = row.prefix;
this.isExpanded = !!row.isExpanded;
this.fromSearchNote = !!row.fromSearchNote;
}
async getNote() {
return this.froca.getNote(this.noteId);
}
getNoteFromCache() {
return this.froca.getNoteFromCache(this.noteId);
}
async getParentNote() {
return this.froca.getNote(this.parentNoteId);
}
/** @returns true if it's top level, meaning its parent is the root note */
isTopLevel() {
return this.parentNoteId === "root";
}
get toString() {
return `FBranch(branchId=${this.branchId})`;
}
get pojo(): Omit<FBranch, "froca"> {
const pojo = { ...this } as any;
delete pojo.froca;
return pojo;
}
}
export default FBranch;

File diff suppressed because it is too large Load Diff

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