mirror of https://github.com/TriliumNext/Notes
Compare commits
No commits in common. "develop" and "v0.92.2-beta" have entirely different histories.
develop
...
v0.92.2-be
@ -0,0 +1,10 @@
|
||||
.git
|
||||
.idea
|
||||
/bin
|
||||
/dist
|
||||
/docs
|
||||
/npm-debug.log
|
||||
node_modules
|
||||
|
||||
src/**/*.ts
|
||||
!src/services/asset_path.ts
|
||||
@ -1,21 +1,3 @@
|
||||
# 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
|
||||
libraries/** linguist-vendored
|
||||
@ -1,4 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [eliandoran]
|
||||
custom: ["https://paypal.me/eliandoran"]
|
||||
github: [zadam]
|
||||
custom: ["https://paypal.me/za4am"]
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
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 }}
|
||||
@ -0,0 +1,78 @@
|
||||
name: Main
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/update**"
|
||||
- "feature/server_esm**"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- ".github/workflows/main-docker.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
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
|
||||
extension: [dmg, zip]
|
||||
- name: linux
|
||||
image: ubuntu-latest
|
||||
extension: [deb, rpm, zip, flatpak]
|
||||
- name: windows
|
||||
image: windows-latest
|
||||
extension: [exe, zip]
|
||||
runs-on: ${{ matrix.os.image }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up node & dependencies
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Run the build
|
||||
uses: ./.github/actions/build-electron
|
||||
with:
|
||||
os: ${{ matrix.os.name }}
|
||||
arch: ${{ matrix.arch }}
|
||||
extension: ${{ matrix.os.extension }}
|
||||
- name: Publish artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: TriliumNextNotes ${{ matrix.os.name }} ${{ matrix.arch }}.zip
|
||||
path: upload/*.zip
|
||||
- name: Publish installer artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: TriliumNextNotes ${{ matrix.os.name }} ${{ matrix.arch }}.${{matrix.os.extension}}
|
||||
path: upload/*.${{ matrix.os.extension }}
|
||||
|
||||
build_linux_server:
|
||||
name: Build Linux Server
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
include:
|
||||
- arch: x64
|
||||
runs-on: ubuntu-latest
|
||||
- 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:
|
||||
arch: ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: TriliumNextNotes linux server ${{ matrix.arch }}
|
||||
path: upload/TriliumNextNotes-linux-${{ matrix.arch }}-${{ github.ref_name }}.tar.xz
|
||||
@ -1,43 +1,27 @@
|
||||
name: playwright
|
||||
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
branches: [ develop ]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
branches: [ develop ]
|
||||
jobs:
|
||||
main:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
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 }}
|
||||
@ -0,0 +1,53 @@
|
||||
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 }}
|
||||
@ -1,49 +1,38 @@
|
||||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
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
|
||||
.cache
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
src/public/app-dist/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
test-output
|
||||
|
||||
apps/*/data
|
||||
apps/*/out
|
||||
upload
|
||||
|
||||
.rollup.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
/result
|
||||
.svelte-kit
|
||||
po-*/
|
||||
.flatpak-builder/
|
||||
|
||||
*.db
|
||||
!integration-tests/db/document.db
|
||||
!integration-tests/db/config.ini
|
||||
integration-tests/db/log
|
||||
integration-tests/db/sessions
|
||||
integration-tests/db/backup
|
||||
integration-tests/db/session_secret.txt
|
||||
|
||||
cert.key
|
||||
cert.crt
|
||||
server-package.json
|
||||
.idea/httpRequests/
|
||||
.idea/shelf/
|
||||
data/
|
||||
data-test/
|
||||
tmp/
|
||||
.eslintcache
|
||||
|
||||
out/
|
||||
|
||||
images/app-icons/mac/*.png
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
@ -0,0 +1,15 @@
|
||||
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/*
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
image:
|
||||
file: .gitpod.dockerfile
|
||||
|
||||
tasks:
|
||||
- before: nvm install 20.15.1 && nvm use 20.15.1
|
||||
init: npm install
|
||||
command: npm run server:start
|
||||
|
||||
ports:
|
||||
- port: 8080
|
||||
onOpen: open-preview
|
||||
@ -1,2 +0,0 @@
|
||||
Adam Zivner <adam.zivner@gmail.com>
|
||||
Adam Zivner <zadam.apps@gmail.com>
|
||||
@ -0,0 +1,5 @@
|
||||
*.html
|
||||
*.md
|
||||
*.yml
|
||||
libraries/*
|
||||
docs/*
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"printWidth": 200,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"proseWrap": "preserve",
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"endOfLine": "lf",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.json"],
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,16 +1,3 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
"recommendations": ["lokalise.i18n-ally", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
# Review comments generated by i18n-ally. Please commit this file.
|
||||
|
||||
reviews:
|
||||
help.inPageSearch:
|
||||
description: >-
|
||||
Describes the shortcut which triggers a search within the current
|
||||
page/note only
|
||||
add_label.to_value:
|
||||
locales:
|
||||
fr:
|
||||
comments:
|
||||
- user:
|
||||
name: Potjoe-97
|
||||
email: giann@LAPTOPT490-GF
|
||||
id: QXec0JUoxfGmMlpch-B1S
|
||||
comment: ''
|
||||
suggestion: vers la valeur
|
||||
type: request_change
|
||||
time: '2024-10-15T16:57:06.188Z'
|
||||
resolved: true
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,32 +1,23 @@
|
||||
{
|
||||
"editor.formatOnSave": false,
|
||||
"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
|
||||
}
|
||||
"editor.formatOnSave": false,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": ["./src/public/translations", "./translations"],
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"github-actions.workflows.pinned.workflows": [".github/workflows/nightly.yml"],
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "vscode.css-language-features"
|
||||
},
|
||||
"npm.exclude": ["**/build", "**/dist", "**/out/**"]
|
||||
}
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
# Build stage
|
||||
FROM node:22.14.0-bullseye-slim AS builder
|
||||
|
||||
# Configure build dependencies in a single layer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
autoconf \
|
||||
automake \
|
||||
g++ \
|
||||
gcc \
|
||||
libtool \
|
||||
make \
|
||||
nasm \
|
||||
libpng-dev \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files for build
|
||||
COPY . .
|
||||
COPY server-package.json package.json
|
||||
|
||||
# Build and cleanup in a single layer
|
||||
RUN cp -R build/src/* src/. && \
|
||||
cp build/docker_healthcheck.js . && \
|
||||
rm docker_healthcheck.ts && \
|
||||
npm install && \
|
||||
npm run build:webpack && \
|
||||
npm prune --omit=dev && \
|
||||
npm cache clean --force && \
|
||||
cp -r src/public/app/doc_notes src/public/app-dist/. && \
|
||||
rm -rf src/public/app/* && \
|
||||
mkdir -p src/public/app/services && \
|
||||
cp -r build/src/public/app/services/mime_type_definitions.js src/public/app/services/mime_type_definitions.js && \
|
||||
rm src/services/asset_path.ts && \
|
||||
rm -r build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22.14.0-bullseye-slim
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/* && \
|
||||
rm -rf /var/cache/apt/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files from builder
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/src ./src
|
||||
COPY --from=builder /usr/src/app/db ./db
|
||||
COPY --from=builder /usr/src/app/docker_healthcheck.js .
|
||||
COPY --from=builder /usr/src/app/start-docker.sh .
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/config-sample.ini .
|
||||
COPY --from=builder /usr/src/app/images ./images
|
||||
COPY --from=builder /usr/src/app/translations ./translations
|
||||
COPY --from=builder /usr/src/app/libraries ./libraries
|
||||
|
||||
# Configure container
|
||||
EXPOSE 8080
|
||||
CMD [ "./start-docker.sh" ]
|
||||
HEALTHCHECK --start-period=10s CMD exec gosu node node docker_healthcheck.js
|
||||
@ -0,0 +1,63 @@
|
||||
# Build stage
|
||||
FROM node:22.14.0-alpine AS builder
|
||||
|
||||
# Configure build dependencies
|
||||
RUN apk add --no-cache --virtual .build-dependencies \
|
||||
autoconf \
|
||||
automake \
|
||||
g++ \
|
||||
gcc \
|
||||
libtool \
|
||||
make \
|
||||
nasm \
|
||||
libpng-dev \
|
||||
python3
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files for build
|
||||
COPY . .
|
||||
COPY server-package.json package.json
|
||||
|
||||
# Build and cleanup in a single layer
|
||||
RUN cp -R build/src/* src/. && \
|
||||
cp build/docker_healthcheck.js . && \
|
||||
rm docker_healthcheck.ts && \
|
||||
npm install && \
|
||||
npm run build:webpack && \
|
||||
npm prune --omit=dev && \
|
||||
npm cache clean --force && \
|
||||
cp -r src/public/app/doc_notes src/public/app-dist/. && \
|
||||
rm -rf src/public/app && \
|
||||
mkdir -p src/public/app/services && \
|
||||
cp -r build/src/public/app/services/mime_type_definitions.js src/public/app/services/mime_type_definitions.js && \
|
||||
rm src/services/asset_path.ts && \
|
||||
rm -r build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22.14.0-alpine
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache su-exec shadow
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy only necessary files from builder
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/src ./src
|
||||
COPY --from=builder /usr/src/app/db ./db
|
||||
COPY --from=builder /usr/src/app/docker_healthcheck.js .
|
||||
COPY --from=builder /usr/src/app/start-docker.sh .
|
||||
COPY --from=builder /usr/src/app/package.json .
|
||||
COPY --from=builder /usr/src/app/config-sample.ini .
|
||||
COPY --from=builder /usr/src/app/images ./images
|
||||
COPY --from=builder /usr/src/app/translations ./translations
|
||||
COPY --from=builder /usr/src/app/libraries ./libraries
|
||||
|
||||
# Add application user
|
||||
RUN adduser -s /bin/false node; exit 0
|
||||
|
||||
# Configure container
|
||||
EXPOSE 8080
|
||||
CMD [ "./start-docker.sh" ]
|
||||
HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js
|
||||
@ -1,2 +1,128 @@
|
||||
> [!IMPORTANT]
|
||||
> TriliumNext started as a fork of the original Trilium repository (`zadam/trilium`). @zadam transferred the original repo to us so the work will continue in https://github.com/TriliumNext/Trilium.
|
||||
# TriliumNext Notes
|
||||
|
||||
 
|
||||
|
||||
[English](./README.md) | [Chinese](./README-ZH_CN.md) | [Russian](./README.ru.md) | [Japanese](./README.ja.md) | [Italian](./README.it.md) | [Spanish](./README.es.md)
|
||||
|
||||
TriliumNext Notes is an open-source, cross-platform 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:
|
||||
|
||||
<a href="https://triliumnext.github.io/Docs/Wiki/screenshot-tour"><img src="https://github.com/TriliumNext/Docs/blob/main/Wiki/images/screenshot.png?raw=true" alt="Trilium Screenshot" width="1000"></a>
|
||||
|
||||
## ⚠️ Why TriliumNext?
|
||||
|
||||
[The original Trilium project is in maintenance mode](https://github.com/zadam/trilium/issues/4620)
|
||||
|
||||
### Migrating from Trilium?
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Notes instance. Just upgrade your Trilium instance to the latest version and [install TriliumNext/Notes as usual](#-installation)
|
||||
|
||||
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.
|
||||
|
||||
## 💬 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)
|
||||
- [Wiki](https://triliumnext.github.io/Docs/) (For common how-to questions and user guides)
|
||||
|
||||
## 🎁 Features
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes))
|
||||
* Rich WYSIWYG note editing including e.g. tables, images and [math](https://triliumnext.github.io/Docs/Wiki/text-notes) with markdown [autoformat](https://triliumnext.github.io/Docs/Wiki/text-notes#autoformat)
|
||||
* Support for editing [notes with source code](https://triliumnext.github.io/Docs/Wiki/code-notes), including syntax highlighting
|
||||
* Fast and easy [navigation between notes](https://triliumnext.github.io/Docs/Wiki/note-navigation), full text search and [note hoisting](https://triliumnext.github.io/Docs/Wiki/note-hoisting)
|
||||
* Seamless [note versioning](https://triliumnext.github.io/Docs/Wiki/note-revisions)
|
||||
* Note [attributes](https://triliumnext.github.io/Docs/Wiki/attributes) can be used for note organization, querying and advanced [scripting](https://triliumnext.github.io/Docs/Wiki/scripts)
|
||||
* [Synchronization](https://triliumnext.github.io/Docs/Wiki/synchronization) with self-hosted sync server
|
||||
* there's a [3rd party service for hosting synchronisation server](https://trilium.cc/paid-hosting)
|
||||
* [Sharing](https://triliumnext.github.io/Docs/Wiki/sharing) (publishing) notes to public internet
|
||||
* Strong [note encryption](https://triliumnext.github.io/Docs/Wiki/protected-notes) with per-note granularity
|
||||
* Sketching diagrams with built-in Excalidraw (note type "canvas")
|
||||
* [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://triliumnext.github.io/Docs/Wiki/scripts) - see [Advanced showcases](https://triliumnext.github.io/Docs/Wiki/advanced-showcases)
|
||||
* [REST API](https://triliumnext.github.io/Docs/Wiki/etapi) for automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile frontend](https://triliumnext.github.io/Docs/Wiki/mobile-frontend) for smartphones and tablets
|
||||
* [Night theme](https://triliumnext.github.io/Docs/Wiki/themes)
|
||||
* [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown)
|
||||
* [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content
|
||||
|
||||
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
|
||||
|
||||
- [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.
|
||||
|
||||
## 🏗 Installation
|
||||
|
||||
### Desktop
|
||||
|
||||
To use TriliumNext on your desktop machine (Linux, MacOS, and Windows) you have a few options:
|
||||
|
||||
* 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.
|
||||
* Access TriliumNext via the web interface of a server installation (see below)
|
||||
* Currently only the latest versions of Chrome & Firefox are supported (and tested).
|
||||
* (Coming Soon) TriliumNext will also be provided as a Flatpak
|
||||
|
||||
#### MacOS
|
||||
Currently when running TriliumNext/Notes on MacOS, you may get the following error:
|
||||
> Apple could not verify "TriliumNext Notes" is free of malware and may harm your Mac or compromise your privacy.
|
||||
|
||||
You will need to run the command on your shell to resolve the error (documented [here](https://github.com/TriliumNext/Notes/issues/329#issuecomment-2287164137)):
|
||||
|
||||
```bash
|
||||
xattr -c "/path/to/Trilium Next.app"
|
||||
```
|
||||
|
||||
### Mobile
|
||||
|
||||
To use TriliumNext on a mobile device:
|
||||
|
||||
* Use a mobile web browser to access the mobile interface of a server installation (see below)
|
||||
* Use of a mobile app is not yet supported ([see here](https://github.com/TriliumNext/Notes/issues/72)) to track mobile improvements.
|
||||
|
||||
### 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).
|
||||
|
||||
## 📝 Documentation
|
||||
|
||||
[See wiki for complete list of documentation pages.](https://triliumnext.github.io/Docs)
|
||||
|
||||
You can also read [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge) to get some inspiration on how you might use TriliumNext.
|
||||
|
||||
## 💻 Contribute
|
||||
|
||||
### Code
|
||||
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Notes.git
|
||||
cd Notes
|
||||
npm install
|
||||
npm run server:start
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
Head on over to our [Docs repo](https://github.com/TriliumNext/Docs)
|
||||
|
||||
## 👏 Shoutouts
|
||||
|
||||
* [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. TriliumNext Notes would not be the same without it.
|
||||
* [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://triliumnext.github.io/Docs/Wiki/relation-map.html) and [link maps](https://triliumnext.github.io/Docs/Wiki/note-map.html#link-map)
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd src/public
|
||||
echo Summary
|
||||
cloc HEAD \
|
||||
--git --md \
|
||||
--include-lang=javascript,typescript
|
||||
|
||||
echo By file
|
||||
cloc HEAD \
|
||||
--git --md \
|
||||
--include-lang=javascript,typescript \
|
||||
--by-file | grep \.js\|
|
||||
@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ $# -eq 0 ]] ; then
|
||||
echo "Missing argument of new version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Missing command: jq"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ;
|
||||
then
|
||||
echo "Version ${VERSION} isn't in format X.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo "There are uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Releasing Trilium $VERSION"
|
||||
|
||||
jq '.version = "'$VERSION'"' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
git add package.json
|
||||
|
||||
npm run chore:update-build-info
|
||||
|
||||
git add src/services/build.ts
|
||||
|
||||
TAG=v$VERSION
|
||||
|
||||
echo "Committing package.json version change"
|
||||
|
||||
git commit -m "chore(release): $VERSION"
|
||||
git push
|
||||
|
||||
echo "Tagging commit with $TAG"
|
||||
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
echo "Updating master"
|
||||
|
||||
git fetch
|
||||
git checkout master
|
||||
git reset --hard origin/master
|
||||
git merge origin/develop
|
||||
git push
|
||||
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1,51 +0,0 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
// consider using rules below, once we have a full TS codebase and can be more strict
|
||||
// tseslint.configs.strictTypeChecked,
|
||||
// tseslint.configs.stylisticTypeChecked,
|
||||
// tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
"simple-import-sort": simpleImportSort
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// add rule overrides here
|
||||
"no-undef": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}
|
||||
],
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"build/*",
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
"src/public/app-dist/*",
|
||||
"src/public/app/doc_notes/*"
|
||||
]
|
||||
}
|
||||
);
|
||||
@ -1,47 +0,0 @@
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
// eslint config just for formatting rules
|
||||
// potentially to be merged with the linting rules into one single config,
|
||||
// once we have fixed the majority of lint errors
|
||||
|
||||
// Go to https://eslint.style/rules/default/${rule_without_prefix} to check the rule details
|
||||
export const stylisticRules = {
|
||||
"@stylistic/indent": [ "error", 4 ],
|
||||
"@stylistic/quotes": [ "error", "double", { avoidEscape: true, allowTemplateLiterals: "always" } ],
|
||||
"@stylistic/semi": [ "error", "always" ],
|
||||
"@stylistic/quote-props": [ "error", "consistent-as-needed" ],
|
||||
"@stylistic/max-len": [ "error", { code: 100 } ],
|
||||
"@stylistic/comma-dangle": [ "error", "never" ],
|
||||
"@stylistic/linebreak-style": [ "error", "unix" ],
|
||||
"@stylistic/array-bracket-spacing": [ "error", "always" ],
|
||||
"@stylistic/object-curly-spacing": [ "error", "always" ],
|
||||
"@stylistic/padded-blocks": [ "error", { classes: "always" } ]
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
files: [ "**/*.{js,ts,mjs,cjs}" ],
|
||||
languageOptions: {
|
||||
parser: tsParser
|
||||
},
|
||||
plugins: {
|
||||
"@stylistic": stylistic
|
||||
},
|
||||
rules: {
|
||||
...stylisticRules
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"build/*",
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
"demo/*",
|
||||
// TriliumNextTODO: check if we want to format packages here as well - for now skipping it
|
||||
"packages/*",
|
||||
"src/public/app-dist/*",
|
||||
"src/public/app/doc_notes/*"
|
||||
]
|
||||
}
|
||||
];
|
||||
@ -1,58 +0,0 @@
|
||||
{
|
||||
"main": "./electron-main.js",
|
||||
"bin": {
|
||||
"trilium": "src/main.js"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server:start-safe": "cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nodemon src/main.ts",
|
||||
"server:start-no-dir": "cross-env TRILIUM_ENV=dev nodemon src/main.ts",
|
||||
"server:start-test": "npm run server:switch && rimraf ./data-test && cross-env TRILIUM_DATA_DIR=./data-test TRILIUM_ENV=dev TRILIUM_PORT=9999 nodemon src/main.ts",
|
||||
"server:qstart": "npm run server:switch && npm run server:start",
|
||||
"server:switch": "rimraf ./node_modules/better-sqlite3 && npm install",
|
||||
"electron:start-no-dir": "cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 electron --inspect=5858 .",
|
||||
"electron:start-nix": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
|
||||
"electron:start-nix-no-dir": "electron-rebuild --version 33.3.1 && cross-env NODE_OPTIONS=\"--import tsx\" TRILIUM_ENV=dev TRILIUM_PORT=37742 nix-shell -p electron_33 --run \"electron ./electron-main.ts --inspect=5858 .\"",
|
||||
"electron:start-prod-no-dir": "npm run build:prepare-dist && cross-env TRILIUM_ENV=prod electron --inspect=5858 .",
|
||||
"electron:start-prod-nix": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_DATA_DIR=./data TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
|
||||
"electron:start-prod-nix-no-dir": "electron-rebuild --version 33.3.1 && npm run build:prepare-dist && cross-env TRILIUM_ENV=dev nix-shell -p electron_33 --run \"electron ./dist/electron-main.js --inspect=5858 .\"",
|
||||
"electron:qstart": "npm run electron:switch && npm run electron:start",
|
||||
"electron:switch": "electron-rebuild",
|
||||
"docs:build": "typedoc",
|
||||
"test": "npm run client:test && npm run server:test",
|
||||
"client:test": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app",
|
||||
"client:coverage": "cross-env TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db TRILIUM_INTEGRATION_TEST=memory vitest --root src/public/app --coverage",
|
||||
"test:playwright": "playwright test --workers 1",
|
||||
"test:integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"test:integration-mem-db": "cross-env nodemon src/main.ts",
|
||||
"test:integration-mem-db-dev": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_ENV=dev TRILIUM_DATA_DIR=./integration-tests/db nodemon src/main.ts",
|
||||
"dev:watch-dist": "tsx ./bin/watch-dist.ts",
|
||||
"dev:format-check": "eslint -c eslint.format.config.js .",
|
||||
"dev:format-fix": "eslint -c eslint.format.config.js . --fix",
|
||||
"dev:linter-check": "eslint .",
|
||||
"dev:linter-fix": "eslint . --fix",
|
||||
"chore:generate-document": "cross-env nodemon ./bin/generate_document.ts 1000",
|
||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.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"
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("app_info", () => {
|
||||
it("get", async () => {
|
||||
const appInfo = await etapi.getEtapi("app-info");
|
||||
expect(appInfo.clipperProtocolVersion).toEqual("1.0");
|
||||
});
|
||||
});
|
||||
*/
|
||||
@ -1,10 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("backup", () => {
|
||||
it("create", async () => {
|
||||
const response = await etapi.putEtapiContent("backup/etapi_test");
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
});
|
||||
*/
|
||||
@ -1,26 +0,0 @@
|
||||
import etapi from "../support/etapi.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("import", () => {
|
||||
// temporarily skip this test since test-export.zip is missing
|
||||
xit("import", async () => {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const zipFileBuffer = fs.readFileSync(path.resolve(scriptDir, "test-export.zip"));
|
||||
|
||||
const response = await etapi.postEtapiContent("notes/root/import", zipFileBuffer);
|
||||
expect(response.status).toEqual(201);
|
||||
|
||||
const { note, branch } = await response.json();
|
||||
|
||||
expect(note.title).toEqual("test-export");
|
||||
expect(branch.parentNoteId).toEqual("root");
|
||||
|
||||
const content = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(content).toContain("test export content");
|
||||
});
|
||||
});
|
||||
*/
|
||||
@ -1,103 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import etapi from "../support/etapi.js";
|
||||
|
||||
/* TriliumNextTODO: port to Vitest
|
||||
etapi.describeEtapi("notes", () => {
|
||||
it("create", async () => {
|
||||
const { note, branch } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content",
|
||||
prefix: "Custom prefix"
|
||||
});
|
||||
|
||||
expect(note.title).toEqual("Hello World!");
|
||||
expect(branch.parentNoteId).toEqual("root");
|
||||
expect(branch.prefix).toEqual("Custom prefix");
|
||||
|
||||
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
|
||||
expect(rNote.title).toEqual("Hello World!");
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(rContent).toEqual("Content");
|
||||
|
||||
const rBranch = await etapi.getEtapi(`branches/${branch.branchId}`);
|
||||
expect(rBranch.parentNoteId).toEqual("root");
|
||||
expect(rBranch.prefix).toEqual("Custom prefix");
|
||||
});
|
||||
|
||||
it("patch", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.patchEtapi(`notes/${note.noteId}`, {
|
||||
title: "new title",
|
||||
type: "code",
|
||||
mime: "text/apl",
|
||||
dateCreated: "2000-01-01 12:34:56.999+0200",
|
||||
utcDateCreated: "2000-01-01 10:34:56.999Z"
|
||||
});
|
||||
|
||||
const rNote = await etapi.getEtapi(`notes/${note.noteId}`);
|
||||
expect(rNote.title).toEqual("new title");
|
||||
expect(rNote.type).toEqual("code");
|
||||
expect(rNote.mime).toEqual("text/apl");
|
||||
expect(rNote.dateCreated).toEqual("2000-01-01 12:34:56.999+0200");
|
||||
expect(rNote.utcDateCreated).toEqual("2000-01-01 10:34:56.999Z");
|
||||
});
|
||||
|
||||
it("update content", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.putEtapiContent(`notes/${note.noteId}/content`, "new content");
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).text();
|
||||
expect(rContent).toEqual("new content");
|
||||
});
|
||||
|
||||
it("create / update binary content", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "file",
|
||||
title: "Hello World!",
|
||||
content: "ZZZ"
|
||||
});
|
||||
|
||||
const updatedContent = crypto.randomBytes(16);
|
||||
|
||||
await etapi.putEtapiContent(`notes/${note.noteId}/content`, updatedContent);
|
||||
|
||||
const rContent = await (await etapi.getEtapiContent(`notes/${note.noteId}/content`)).arrayBuffer();
|
||||
expect(Buffer.from(new Uint8Array(rContent))).toEqual(updatedContent);
|
||||
});
|
||||
|
||||
it("delete note", async () => {
|
||||
const { note } = await etapi.postEtapi("create-note", {
|
||||
parentNoteId: "root",
|
||||
type: "text",
|
||||
title: "Hello World!",
|
||||
content: "Content"
|
||||
});
|
||||
|
||||
await etapi.deleteEtapi(`notes/${note.noteId}`);
|
||||
|
||||
const resp = await etapi.getEtapiResponse(`notes/${note.noteId}`);
|
||||
expect(resp.status).toEqual(404);
|
||||
|
||||
const error = await resp.json();
|
||||
expect(error.status).toEqual(404);
|
||||
expect(error.code).toEqual("NOTE_NOT_FOUND");
|
||||
expect(error.message).toEqual(`Note '${note.noteId}' not found.`);
|
||||
});
|
||||
});
|
||||
*/
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"entryPoints": [
|
||||
"src/services/backend_script_entrypoint.ts",
|
||||
"src/public/app/services/frontend_script_entrypoint.ts"
|
||||
],
|
||||
"plugin": [
|
||||
"typedoc-plugin-missing-exports"
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "html",
|
||||
"path": "./docs/Script API"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
# 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
|
||||
@ -1 +0,0 @@
|
||||
VITE_CKEDITOR_ENABLE_INSPECTOR=false
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript"
|
||||
},
|
||||
"target": "es2016"
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [
|
||||
...baseConfig
|
||||
];
|
||||
@ -1,84 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
|
||||
export default `assets/v${packageJson.version}`;
|
||||
@ -1,82 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,711 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,283 +0,0 @@
|
||||
import FlexContainer from "../widgets/containers/flex_container.js";
|
||||
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
|
||||
import TabRowWidget from "../widgets/tab_row.js";
|
||||
import TitleBarButtonsWidget from "../widgets/title_bar_buttons.js";
|
||||
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
|
||||
import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteTitleWidget from "../widgets/note_title.js";
|
||||
import OwnedAttributeListWidget from "../widgets/ribbon_widgets/owned_attribute_list.js";
|
||||
import NoteActionsWidget from "../widgets/buttons/note_actions.js";
|
||||
import NoteDetailWidget from "../widgets/note_detail.js";
|
||||
import RibbonContainer from "../widgets/containers/ribbon_container.js";
|
||||
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
|
||||
import InheritedAttributesWidget from "../widgets/ribbon_widgets/inherited_attribute_list.js";
|
||||
import NoteListWidget from "../widgets/note_list.js";
|
||||
import SearchDefinitionWidget from "../widgets/ribbon_widgets/search_definition.js";
|
||||
import SqlResultWidget from "../widgets/sql_result.js";
|
||||
import SqlTableSchemasWidget from "../widgets/sql_table_schemas.js";
|
||||
import FilePropertiesWidget from "../widgets/ribbon_widgets/file_properties.js";
|
||||
import ImagePropertiesWidget from "../widgets/ribbon_widgets/image_properties.js";
|
||||
import NotePropertiesWidget from "../widgets/ribbon_widgets/note_properties.js";
|
||||
import NoteIconWidget from "../widgets/note_icon.js";
|
||||
import SearchResultWidget from "../widgets/search_result.js";
|
||||
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
|
||||
import RootContainer from "../widgets/containers/root_container.js";
|
||||
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
|
||||
import SpacerWidget from "../widgets/spacer.js";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
|
||||
import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js";
|
||||
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
|
||||
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
|
||||
import BasicPropertiesWidget from "../widgets/ribbon_widgets/basic_properties.js";
|
||||
import NoteInfoWidget from "../widgets/ribbon_widgets/note_info_widget.js";
|
||||
import BookPropertiesWidget from "../widgets/ribbon_widgets/book_properties.js";
|
||||
import NoteMapRibbonWidget from "../widgets/ribbon_widgets/note_map.js";
|
||||
import NotePathsWidget from "../widgets/ribbon_widgets/note_paths.js";
|
||||
import SimilarNotesWidget from "../widgets/ribbon_widgets/similar_notes.js";
|
||||
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
|
||||
import EditButton from "../widgets/floating_buttons/edit_button.js";
|
||||
import EditedNotesWidget from "../widgets/ribbon_widgets/edited_notes.js";
|
||||
import ShowTocWidgetButton from "../widgets/buttons/show_toc_widget_button.js";
|
||||
import ShowHighlightsListWidgetButton from "../widgets/buttons/show_highlights_list_widget_button.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
|
||||
import SharedInfoWidget from "../widgets/shared_info.js";
|
||||
import FindWidget from "../widgets/find.js";
|
||||
import TocWidget from "../widgets/toc.js";
|
||||
import HighlightsListWidget from "../widgets/highlights_list.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import AboutDialog from "../widgets/dialogs/about.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import AddLinkDialog from "../widgets/dialogs/add_link.js";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
|
||||
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
|
||||
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
|
||||
import LauncherContainer from "../widgets/containers/launcher_container.js";
|
||||
import RevisionsButton from "../widgets/buttons/revisions_button.js";
|
||||
import CodeButtonsWidget from "../widgets/floating_buttons/code_buttons.js";
|
||||
import ApiLogWidget from "../widgets/api_log.js";
|
||||
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
|
||||
import ScriptExecutorWidget from "../widgets/ribbon_widgets/script_executor.js";
|
||||
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
|
||||
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
|
||||
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
|
||||
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
|
||||
import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolbar.js";
|
||||
import options from "../services/options.js";
|
||||
import utils, { hasTouchBar } from "../services/utils.js";
|
||||
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
|
||||
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
|
||||
import CloseZenButton from "../widgets/close_zen_button.js";
|
||||
import type { AppContext } from "../components/app_context.js";
|
||||
import type { WidgetsByParent } from "../services/bundle.js";
|
||||
import SwitchSplitOrientationButton from "../widgets/floating_buttons/switch_layout_button.js";
|
||||
import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_button.js";
|
||||
import PngExportButton from "../widgets/floating_buttons/png_export_button.js";
|
||||
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
|
||||
import { applyModals } from "./layout_commons.js";
|
||||
|
||||
export default class DesktopLayout {
|
||||
|
||||
private customWidgets: WidgetsByParent;
|
||||
|
||||
constructor(customWidgets: WidgetsByParent) {
|
||||
this.customWidgets = customWidgets;
|
||||
}
|
||||
|
||||
getRootWidget(appContext: AppContext) {
|
||||
appContext.noteTreeWidget = new NoteTreeWidget();
|
||||
|
||||
const launcherPaneIsHorizontal = options.get("layoutOrientation") === "horizontal";
|
||||
const launcherPane = this.#buildLauncherPane(launcherPaneIsHorizontal);
|
||||
const isElectron = utils.isElectron();
|
||||
const isMac = window.glob.platform === "darwin";
|
||||
const isWindows = window.glob.platform === "win32";
|
||||
const hasNativeTitleBar = window.glob.hasNativeTitleBar;
|
||||
|
||||
/**
|
||||
* If true, the tab bar is displayed above the launcher pane with full width; if false (default), the tab bar is displayed in the rest pane.
|
||||
* On macOS we need to force the full-width tab bar on Electron in order to allow the semaphore (window controls) enough space.
|
||||
*/
|
||||
const fullWidthTabBar = launcherPaneIsHorizontal || (isElectron && !hasNativeTitleBar && isMac);
|
||||
const customTitleBarButtons = !hasNativeTitleBar && !isMac && !isWindows;
|
||||
|
||||
const rootContainer = new RootContainer(true)
|
||||
.setParent(appContext)
|
||||
.class((launcherPaneIsHorizontal ? "horizontal" : "vertical") + "-layout")
|
||||
.optChild(
|
||||
fullWidthTabBar,
|
||||
new FlexContainer("row")
|
||||
.class("tab-row-container")
|
||||
.child(new FlexContainer("row").id("tab-row-left-spacer"))
|
||||
.optChild(launcherPaneIsHorizontal, new LeftPaneToggleWidget(true))
|
||||
.child(new TabRowWidget().class("full-width"))
|
||||
.optChild(customTitleBarButtons, new TitleBarButtonsWidget())
|
||||
.css("height", "40px")
|
||||
.css("background-color", "var(--launcher-pane-background-color)")
|
||||
.setParent(appContext)
|
||||
)
|
||||
.optChild(launcherPaneIsHorizontal, launcherPane)
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.css("flex-grow", "1")
|
||||
.id("horizontal-main-container")
|
||||
.optChild(!launcherPaneIsHorizontal, launcherPane)
|
||||
.child(
|
||||
new LeftPaneContainer()
|
||||
.optChild(!launcherPaneIsHorizontal, new QuickSearchWidget())
|
||||
.child(appContext.noteTreeWidget)
|
||||
.child(...this.customWidgets.get("left-pane"))
|
||||
)
|
||||
.child(
|
||||
new FlexContainer("column")
|
||||
.id("rest-pane")
|
||||
.css("flex-grow", "1")
|
||||
.optChild(!fullWidthTabBar, new FlexContainer("row").child(new TabRowWidget()).optChild(customTitleBarButtons, new TitleBarButtonsWidget()).css("height", "40px"))
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.filling()
|
||||
.collapsible()
|
||||
.id("vertical-main-container")
|
||||
.child(
|
||||
new FlexContainer("column")
|
||||
.filling()
|
||||
.collapsible()
|
||||
.id("center-pane")
|
||||
.child(
|
||||
new SplitNoteContainer(() =>
|
||||
new NoteWrapperWidget()
|
||||
.child(
|
||||
new FlexContainer("row")
|
||||
.class("title-row")
|
||||
.css("height", "50px")
|
||||
.css("min-height", "50px")
|
||||
.css("align-items", "center")
|
||||
.cssBlock(".title-row > * { margin: 5px; }")
|
||||
.child(new NoteIconWidget())
|
||||
.child(new NoteTitleWidget())
|
||||
.child(new SpacerWidget(0, 1))
|
||||
.child(new MovePaneButton(true))
|
||||
.child(new MovePaneButton(false))
|
||||
.child(new ClosePaneButton())
|
||||
.child(new CreatePaneButton())
|
||||
)
|
||||
.child(
|
||||
new RibbonContainer()
|
||||
// the order of the widgets matter. Some of these want to "activate" themselves
|
||||
// when visible. When this happens to multiple of them, the first one "wins".
|
||||
// promoted attributes should always win.
|
||||
.ribbon(new ClassicEditorToolbar())
|
||||
.ribbon(new ScriptExecutorWidget())
|
||||
.ribbon(new SearchDefinitionWidget())
|
||||
.ribbon(new EditedNotesWidget())
|
||||
.ribbon(new BookPropertiesWidget())
|
||||
.ribbon(new NotePropertiesWidget())
|
||||
.ribbon(new FilePropertiesWidget())
|
||||
.ribbon(new ImagePropertiesWidget())
|
||||
.ribbon(new BasicPropertiesWidget())
|
||||
.ribbon(new OwnedAttributeListWidget())
|
||||
.ribbon(new InheritedAttributesWidget())
|
||||
.ribbon(new NotePathsWidget())
|
||||
.ribbon(new NoteMapRibbonWidget())
|
||||
.ribbon(new SimilarNotesWidget())
|
||||
.ribbon(new NoteInfoWidget())
|
||||
.button(new RevisionsButton())
|
||||
.button(new NoteActionsWidget())
|
||||
)
|
||||
.child(new SharedInfoWidget())
|
||||
.child(new WatchedFileUpdateStatusWidget())
|
||||
.child(
|
||||
new FloatingButtons()
|
||||
.child(new RefreshButton())
|
||||
.child(new SwitchSplitOrientationButton())
|
||||
.child(new ToggleReadOnlyButton())
|
||||
.child(new EditButton())
|
||||
.child(new ShowTocWidgetButton())
|
||||
.child(new ShowHighlightsListWidgetButton())
|
||||
.child(new CodeButtonsWidget())
|
||||
.child(new RelationMapButtons())
|
||||
.child(new GeoMapButtons())
|
||||
.child(new CopyImageReferenceButton())
|
||||
.child(new SvgExportButton())
|
||||
.child(new PngExportButton())
|
||||
.child(new BacklinksWidget())
|
||||
.child(new ContextualHelpButton())
|
||||
.child(new HideFloatingButtonsButton())
|
||||
)
|
||||
.child(
|
||||
new ScrollingContainer()
|
||||
.filling()
|
||||
.child(new PromotedAttributesWidget())
|
||||
.child(new SqlTableSchemasWidget())
|
||||
.child(new NoteDetailWidget())
|
||||
.child(new NoteListWidget())
|
||||
.child(new SearchResultWidget())
|
||||
.child(new SqlResultWidget())
|
||||
.child(new ScrollPaddingWidget())
|
||||
)
|
||||
.child(new ApiLogWidget())
|
||||
.child(new FindWidget())
|
||||
.child(
|
||||
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
|
||||
...this.customWidgets.get("note-detail-pane")
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(...this.customWidgets.get("center-pane"))
|
||||
)
|
||||
.child(
|
||||
new RightPaneContainer()
|
||||
.child(new TocWidget())
|
||||
.child(new HighlightsListWidget())
|
||||
.child(...this.customWidgets.get("right-pane"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.child(new CloseZenButton())
|
||||
|
||||
// Desktop-specific dialogs.
|
||||
.child(new PasswordNoteSetDialog())
|
||||
.child(new UploadAttachmentsDialog());
|
||||
|
||||
applyModals(rootContainer);
|
||||
return rootContainer;
|
||||
}
|
||||
|
||||
#buildLauncherPane(isHorizontal: boolean) {
|
||||
let launcherPane;
|
||||
|
||||
if (isHorizontal) {
|
||||
launcherPane = new FlexContainer("row").css("height", "53px").class("horizontal").child(new LauncherContainer(true)).child(new GlobalMenuWidget(true));
|
||||
} else {
|
||||
launcherPane = new FlexContainer("column")
|
||||
.css("width", "53px")
|
||||
.class("vertical")
|
||||
.child(new GlobalMenuWidget(false))
|
||||
.child(new LauncherContainer(false))
|
||||
.child(new LeftPaneToggleWidget(false));
|
||||
}
|
||||
|
||||
launcherPane.id("launcher-pane");
|
||||
return launcherPane;
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
|
||||
import AboutDialog from "../widgets/dialogs/about.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import AddLinkDialog from "../widgets/dialogs/add_link.js";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
rootContainer
|
||||
.child(new BulkActionsDialog())
|
||||
.child(new AboutDialog())
|
||||
.child(new HelpDialog())
|
||||
.child(new RecentChangesDialog())
|
||||
.child(new BranchPrefixDialog())
|
||||
.child(new SortChildNotesDialog())
|
||||
.child(new IncludeNoteDialog())
|
||||
.child(new NoteTypeChooserDialog())
|
||||
.child(new JumpToNoteDialog())
|
||||
.child(new AddLinkDialog())
|
||||
.child(new CloneToDialog())
|
||||
.child(new MoveToDialog())
|
||||
.child(new ImportDialog())
|
||||
.child(new ExportDialog())
|
||||
.child(new MarkdownImportDialog())
|
||||
.child(new ProtectedSessionPasswordDialog())
|
||||
.child(new RevisionsDialog())
|
||||
.child(new DeleteNotesDialog())
|
||||
.child(new InfoDialog())
|
||||
.child(new ConfirmDialog())
|
||||
.child(new PromptDialog())
|
||||
.child(new IncorrectCpuArchDialog())
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
// @ts-ignore - module = undefined
|
||||
// Required for correct loading of scripts in Electron
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
@ -1,18 +0,0 @@
|
||||
import appContext from "./components/app_context.js";
|
||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||
import glob from "./services/glob.js";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
glob.setupGlobs();
|
||||
|
||||
await appContext.earlyInit();
|
||||
|
||||
noteAutocompleteService.init();
|
||||
|
||||
// A dynamic import is required for layouts since they initialize components which require translations.
|
||||
const MobileLayout = (await import("./layouts/mobile_layout.js")).default;
|
||||
|
||||
appContext.setLayout(new MobileLayout());
|
||||
appContext.start();
|
||||
@ -1,5 +0,0 @@
|
||||
import $ from "jquery";
|
||||
(window as any).$ = $;
|
||||
(window as any).jQuery = $;
|
||||
|
||||
$("body").show();
|
||||
@ -1,112 +0,0 @@
|
||||
import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { AttributeRow } from "./load_results.js";
|
||||
|
||||
async function addLabel(noteId: string, name: string, value: string = "") {
|
||||
await server.put(`notes/${noteId}/attribute`, {
|
||||
type: "label",
|
||||
name: name,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
|
||||
async function setLabel(noteId: string, name: string, value: string = "") {
|
||||
await server.put(`notes/${noteId}/set-attribute`, {
|
||||
type: "label",
|
||||
name: name,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAttributeById(noteId: string, attributeId: string) {
|
||||
await server.remove(`notes/${noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a label identified by its name from the given note, if it exists. Note that the label must be owned, i.e.
|
||||
* it will not remove inherited attributes.
|
||||
*
|
||||
* @param note the note from which to remove the label.
|
||||
* @param labelName the name of the label to remove.
|
||||
* @returns `true` if an attribute was identified and removed, `false` otherwise.
|
||||
*/
|
||||
function removeOwnedLabelByName(note: FNote, labelName: string) {
|
||||
const label = note.getOwnedLabel(labelName);
|
||||
if (label) {
|
||||
removeAttributeById(note.noteId, label.attributeId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the attribute of the given note to the provided value if its truthy, or removes the attribute if the value is falsy.
|
||||
* For an attribute with an empty value, pass an empty string instead.
|
||||
*
|
||||
* @param note the note to set the attribute to.
|
||||
* @param type the type of attribute (label or relation).
|
||||
* @param name the name of the attribute to set.
|
||||
* @param value the value of the attribute to set.
|
||||
*/
|
||||
async function setAttribute(note: FNote, type: "label" | "relation", name: string, value: string | null | undefined) {
|
||||
if (value) {
|
||||
// Create or update the attribute.
|
||||
await server.put(`notes/${note.noteId}/set-attribute`, { type, name, value });
|
||||
} else {
|
||||
// Remove the attribute if it exists on the server but we don't define a value for it.
|
||||
const attributeId = note.getAttribute(type, name)?.attributeId;
|
||||
if (attributeId) {
|
||||
await server.remove(`notes/${note.noteId}/attributes/${attributeId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns - returns true if this attribute has the potential to influence the note in the argument.
|
||||
* That can happen in multiple ways:
|
||||
* 1. attribute is owned by the note
|
||||
* 2. attribute is owned by the template of the note
|
||||
* 3. attribute is owned by some note's ancestor and is inheritable
|
||||
*/
|
||||
function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefined) {
|
||||
if (!affectedNote || !attrRow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attrNote = attrRow.noteId && froca.notes[attrRow.noteId];
|
||||
|
||||
if (!attrNote) {
|
||||
// the note (owner of the attribute) is not even loaded into the cache, so it should not affect anything else
|
||||
return false;
|
||||
}
|
||||
|
||||
const owningNotes = [affectedNote, ...affectedNote.getNotesToInheritAttributesFrom()];
|
||||
|
||||
for (const owningNote of owningNotes) {
|
||||
if (owningNote.noteId === attrNote.noteId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This doesn't seem right.
|
||||
//@ts-ignore
|
||||
if (this.isInheritable) {
|
||||
for (const owningNote of owningNotes) {
|
||||
if (owningNote.hasAncestor(attrNote.noteId, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
addLabel,
|
||||
setLabel,
|
||||
setAttribute,
|
||||
removeAttributeById,
|
||||
removeOwnedLabelByName,
|
||||
isAffecting
|
||||
};
|
||||
@ -1,276 +0,0 @@
|
||||
import utils from "./utils.js";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptions } from "./toast.js";
|
||||
import froca from "./froca.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import ws from "./ws.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
|
||||
// TODO: Deduplicate type with server
|
||||
interface Response {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
async function moveBeforeBranch(branchIdsToMove: string[], beforeBranchId: string) {
|
||||
branchIdsToMove = filterRootNote(branchIdsToMove);
|
||||
branchIdsToMove = filterSearchBranches(branchIdsToMove);
|
||||
|
||||
const beforeBranch = froca.getBranch(beforeBranchId);
|
||||
if (!beforeBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (beforeBranch.noteId === "root" || utils.isLaunchBarConfig(beforeBranch.noteId)) {
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const branchIdToMove of branchIdsToMove) {
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-before/${beforeBranchId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveAfterBranch(branchIdsToMove: string[], afterBranchId: string) {
|
||||
branchIdsToMove = filterRootNote(branchIdsToMove);
|
||||
branchIdsToMove = filterSearchBranches(branchIdsToMove);
|
||||
|
||||
const afterNote = await froca.getBranch(afterBranchId)?.getNote();
|
||||
if (!afterNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const forbiddenNoteIds = ["root", hoistedNoteService.getHoistedNoteId(), "_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers"];
|
||||
|
||||
if (forbiddenNoteIds.includes(afterNote.noteId)) {
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
branchIdsToMove.reverse(); // need to reverse to keep the note order
|
||||
|
||||
for (const branchIdToMove of branchIdsToMove) {
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-after/${afterBranchId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveToParentNote(branchIdsToMove: string[], newParentBranchId: string) {
|
||||
const newParentBranch = froca.getBranch(newParentBranchId);
|
||||
if (!newParentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newParentBranch.noteId === "_lbRoot") {
|
||||
toastService.showError(t("branches.cannot-move-notes-here"));
|
||||
return;
|
||||
}
|
||||
|
||||
branchIdsToMove = filterRootNote(branchIdsToMove);
|
||||
|
||||
for (const branchIdToMove of branchIdsToMove) {
|
||||
const branchToMove = froca.getBranch(branchIdToMove);
|
||||
|
||||
if (!branchToMove || branchToMove.noteId === hoistedNoteService.getHoistedNoteId() || (await branchToMove.getParentNote())?.type === "search") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-to/${newParentBranchId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = false) {
|
||||
branchIdsToDelete = filterRootNote(branchIdsToDelete);
|
||||
|
||||
if (branchIdsToDelete.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { proceed, deleteAllClones, eraseNotes } = await new Promise<ResolveOptions>((res) =>
|
||||
appContext.triggerCommand("showDeleteNotesDialog", { branchIdsToDelete, callback: res, forceDeleteAllClones })
|
||||
);
|
||||
|
||||
if (!proceed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await activateParentNotePath();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
|
||||
let counter = 0;
|
||||
|
||||
for (const branchIdToDelete of branchIdsToDelete) {
|
||||
counter++;
|
||||
|
||||
const last = counter === branchIdsToDelete.length;
|
||||
const query = `?taskId=${taskId}&eraseNotes=${eraseNotes ? "true" : "false"}&last=${last ? "true" : "false"}`;
|
||||
|
||||
const branch = froca.getBranch(branchIdToDelete);
|
||||
|
||||
if (deleteAllClones && branch) {
|
||||
await server.remove(`notes/${branch.noteId}${query}`);
|
||||
} else {
|
||||
await server.remove(`branches/${branchIdToDelete}${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (eraseNotes) {
|
||||
utils.reloadFrontendApp("erasing notes requires reload");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function activateParentNotePath() {
|
||||
// this is not perfect, maybe we should find the next/previous sibling, but that's more complex
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
const parentNotePathArr = activeContext?.notePathArray.slice(0, -1);
|
||||
|
||||
if (parentNotePathArr && parentNotePathArr.length > 0) {
|
||||
activeContext?.setNote(parentNotePathArr.join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
async function moveNodeUpInHierarchy(node: Fancytree.FancytreeNode) {
|
||||
if (hoistedNoteService.isHoistedNode(node) || hoistedNoteService.isTopLevelNode(node) || node.getParent().data.noteType === "search") {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetBranchId = node.getParent().data.branchId;
|
||||
const branchIdToMove = node.data.branchId;
|
||||
|
||||
const resp = await server.put<Response>(`branches/${branchIdToMove}/move-after/${targetBranchId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hoistedNoteService.isTopLevelNode(node) && node.getParent().getChildren().length <= 1) {
|
||||
node.getParent().folder = false;
|
||||
node.getParent().renderTitle();
|
||||
}
|
||||
}
|
||||
|
||||
function filterSearchBranches(branchIds: string[]) {
|
||||
return branchIds.filter((branchId) => !branchId.startsWith("virt-"));
|
||||
}
|
||||
|
||||
function filterRootNote(branchIds: string[]) {
|
||||
const hoistedNoteId = hoistedNoteService.getHoistedNoteId();
|
||||
|
||||
return branchIds.filter((branchId) => {
|
||||
const branch = froca.getBranch(branchId);
|
||||
if (!branch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return branch.noteId !== "root" && branch.noteId !== hoistedNoteId;
|
||||
});
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
return {
|
||||
id: id,
|
||||
title: t("branches.delete-status"),
|
||||
message: message,
|
||||
icon: "trash"
|
||||
};
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "deleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.delete-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("branches.delete-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "undeleteNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("branches.undeleting-notes-in-progress", { count: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("branches.undeleting-notes-finished-successfully"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
}
|
||||
});
|
||||
|
||||
async function cloneNoteToBranch(childNoteId: string, parentBranchId: string, prefix?: string) {
|
||||
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-branch/${parentBranchId}`, {
|
||||
prefix: prefix
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneNoteToParentNote(childNoteId: string, parentNoteId: string, prefix?: string) {
|
||||
const resp = await server.put<Response>(`notes/${childNoteId}/clone-to-note/${parentNoteId}`, {
|
||||
prefix: prefix
|
||||
});
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
}
|
||||
}
|
||||
|
||||
// beware that the first arg is noteId and the second is branchId!
|
||||
async function cloneNoteAfter(noteId: string, afterBranchId: string) {
|
||||
const resp = await server.put<Response>(`notes/${noteId}/clone-after/${afterBranchId}`);
|
||||
|
||||
if (!resp.success) {
|
||||
toastService.showError(resp.message);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
moveBeforeBranch,
|
||||
moveAfterBranch,
|
||||
moveToParentNote,
|
||||
deleteNotes,
|
||||
moveNodeUpInHierarchy,
|
||||
cloneNoteAfter,
|
||||
cloneNoteToBranch,
|
||||
cloneNoteToParentNote
|
||||
};
|
||||
@ -1,37 +0,0 @@
|
||||
export function copyText(text: string) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} else {
|
||||
// Fallback method: https://stackoverflow.com/a/72239825
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
try {
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
return document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyTextWithToast(text: string) {
|
||||
const t = (await import("./i18n.js")).t;
|
||||
const toast = (await import("./toast.js")).default;
|
||||
|
||||
if (copyText(text)) {
|
||||
toast.showMessage(t("clipboard.copy_success"));
|
||||
} else {
|
||||
toast.showError(t("clipboard.copy_failed"));
|
||||
}
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { FNoteRow } from "../entities/fnote.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
async function getInboxNote() {
|
||||
const note = await server.get<FNoteRow>(`special-notes/inbox/${dayjs().format("YYYY-MM-DD")}`, "date-note");
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function getTodayNote() {
|
||||
return await getDayNote(dayjs().format("YYYY-MM-DD"));
|
||||
}
|
||||
|
||||
async function getDayNote(date: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/days/${date}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function getWeekFirstDayNote(date: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/week-first-day/${date}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function getWeekNote(week: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/weeks/${week}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note?.noteId);
|
||||
}
|
||||
|
||||
async function getMonthNote(month: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/months/${month}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function getQuarterNote(quarter: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/quarters/${quarter}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function getYearNote(year: string) {
|
||||
const note = await server.get<FNoteRow>(`special-notes/years/${year}`, "date-note");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function createSqlConsole() {
|
||||
const note = await server.post<FNoteRow>("special-notes/sql-console");
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
async function createSearchNote(opts = {}) {
|
||||
const note = await server.post<FNoteRow>("special-notes/search-note", opts);
|
||||
|
||||
await ws.waitForMaxKnownEntityChangeId();
|
||||
|
||||
return await froca.getNote(note.noteId);
|
||||
}
|
||||
|
||||
export default {
|
||||
getInboxNote,
|
||||
getTodayNote,
|
||||
getDayNote,
|
||||
getWeekFirstDayNote,
|
||||
getWeekNote,
|
||||
getQuarterNote,
|
||||
getMonthNote,
|
||||
getYearNote,
|
||||
createSqlConsole,
|
||||
createSearchNote
|
||||
};
|
||||
@ -1,66 +0,0 @@
|
||||
import { Modal } from "bootstrap";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions } from "../widgets/dialogs/confirm.js";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true) {
|
||||
if (closeActDialog) {
|
||||
closeActiveDialog();
|
||||
glob.activeDialog = $dialog;
|
||||
}
|
||||
|
||||
saveFocusedElement();
|
||||
Modal.getOrCreateInstance($dialog[0]).show();
|
||||
|
||||
$dialog.on("hidden.bs.modal", () => {
|
||||
const $autocompleteEl = $(".aa-input");
|
||||
if ("autocomplete" in $autocompleteEl) {
|
||||
$autocompleteEl.autocomplete("close");
|
||||
}
|
||||
|
||||
if (!glob.activeDialog || glob.activeDialog === $dialog) {
|
||||
focusSavedElement();
|
||||
}
|
||||
});
|
||||
|
||||
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
|
||||
keyboardActionsService.updateDisplayedShortcuts($dialog);
|
||||
|
||||
return $dialog;
|
||||
}
|
||||
|
||||
export function closeActiveDialog() {
|
||||
if (glob.activeDialog) {
|
||||
Modal.getOrCreateInstance(glob.activeDialog[0]).hide();
|
||||
glob.activeDialog = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function info(message: string) {
|
||||
return new Promise((res) => appContext.triggerCommand("showInfoDialog", { message, callback: res }));
|
||||
}
|
||||
|
||||
async function confirm(message: string) {
|
||||
return new Promise((res) =>
|
||||
appContext.triggerCommand("showConfirmDialog", <ConfirmWithMessageOptions>{
|
||||
message,
|
||||
callback: (x: false | ConfirmDialogOptions) => res(x && x.confirmed)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmDeleteNoteBoxWithNote(title: string) {
|
||||
return new Promise<ConfirmDialogResult | undefined>((res) => appContext.triggerCommand("showConfirmDeleteNoteBoxWithNoteDialog", { title, callback: res }));
|
||||
}
|
||||
|
||||
async function prompt(props: PromptDialogOptions) {
|
||||
return new Promise<string | null>((res) => appContext.triggerCommand("showPromptDialog", { ...props, callback: res }));
|
||||
}
|
||||
|
||||
export default {
|
||||
info,
|
||||
confirm,
|
||||
confirmDeleteNoteBoxWithNote,
|
||||
prompt
|
||||
};
|
||||
@ -1,53 +0,0 @@
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { getCurrentLanguage } from "./i18n.js";
|
||||
import { formatCodeBlocks } from "./syntax_highlight.js";
|
||||
|
||||
export default function renderDoc(note: FNote) {
|
||||
return new Promise<JQuery<HTMLElement>>((resolve) => {
|
||||
let docName = note.getLabelValue("docName");
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
// find doc based on language
|
||||
const url = getUrl(docName, getCurrentLanguage());
|
||||
$content.load(url, (response, status) => {
|
||||
// fallback to english doc if no translation available
|
||||
if (status === "error") {
|
||||
const fallbackUrl = getUrl(docName, "en");
|
||||
$content.load(fallbackUrl, () => {
|
||||
processContent(fallbackUrl, $content)
|
||||
resolve($content);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
processContent(url, $content);
|
||||
resolve($content);
|
||||
});
|
||||
} else {
|
||||
resolve($content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
});
|
||||
}
|
||||
|
||||
function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
const dir = url.substring(0, url.lastIndexOf("/"));
|
||||
|
||||
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
|
||||
$content.find("img").each((i, el) => {
|
||||
const $img = $(el);
|
||||
$img.attr("src", dir + "/" + $img.attr("src"));
|
||||
});
|
||||
|
||||
formatCodeBlocks($content);
|
||||
}
|
||||
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
const basePath = window.glob.isDev ? new URL(window.glob.assetPath).pathname : window.glob.assetPath;
|
||||
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
let $lastFocusedElement: JQuery<HTMLElement> | null;
|
||||
|
||||
// perhaps there should be saved focused element per tab?
|
||||
export function saveFocusedElement() {
|
||||
$lastFocusedElement = $(":focus");
|
||||
}
|
||||
|
||||
export function focusSavedElement() {
|
||||
if (!$lastFocusedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($lastFocusedElement.hasClass("ck")) {
|
||||
// must handle CKEditor separately because of this bug: https://github.com/ckeditor/ckeditor5/issues/607
|
||||
// the bug manifests itself in resetting the cursor position to the first character - jumping above
|
||||
|
||||
const editor = $lastFocusedElement.closest(".ck-editor__editable").prop("ckeditorInstance");
|
||||
|
||||
if (editor) {
|
||||
editor.editing.view.focus();
|
||||
} else {
|
||||
console.log("Could not find CKEditor instance to focus last element");
|
||||
}
|
||||
} else {
|
||||
$lastFocusedElement.focus();
|
||||
}
|
||||
|
||||
$lastFocusedElement = null;
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
/**
|
||||
* The front script API is accessible to code notes with the "JS (frontend)" language.
|
||||
*
|
||||
* The entire API is exposed as a single global: {@link api}
|
||||
*
|
||||
* @module Frontend Script API
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file creates the entrypoint for TypeDoc that simulates the context from within a
|
||||
* script note.
|
||||
*
|
||||
* Make sure to keep in line with frontend's `script_context.ts`.
|
||||
*/
|
||||
|
||||
export type { default as BasicWidget } from "../widgets/basic_widget.js";
|
||||
export type { default as FAttachment } from "../entities/fattachment.js";
|
||||
export type { default as FAttribute } from "../entities/fattribute.js";
|
||||
export type { default as FBranch } from "../entities/fbranch.js";
|
||||
export type { default as FNote } from "../entities/fnote.js";
|
||||
export type { Api } from "./frontend_script_api.js";
|
||||
export type { default as NoteContextAwareWidget } from "../widgets/note_context_aware_widget.js";
|
||||
export type { default as RightPanelWidget } from "../widgets/right_panel_widget.js";
|
||||
|
||||
import FrontendScriptApi, { type Api } from "./frontend_script_api.js";
|
||||
|
||||
//@ts-expect-error
|
||||
export const api: Api = new FrontendScriptApi();
|
||||
@ -1,19 +0,0 @@
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("i18n", () => {
|
||||
it("translations are valid JSON", () => {
|
||||
for (const locale of LOCALES) {
|
||||
if (locale.contentOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const translationPath = join(__dirname, "..", "translations", locale.id, "translation.json");
|
||||
const translationFile = readFileSync(translationPath, { encoding: "utf-8" });
|
||||
expect(() => JSON.parse(translationFile), `JSON error while parsing locale '${locale.id}' at "${translationPath}"`)
|
||||
.not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,44 +0,0 @@
|
||||
import options from "./options.js";
|
||||
import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import type { Locale } from "@triliumnext/commons";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
export async function initLocale() {
|
||||
const locale = (options.get("locale") as string) || "en";
|
||||
|
||||
locales = await server.get<Locale[]>("options/locales");
|
||||
|
||||
await i18next.use(i18nextHttpBackend).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
backend: {
|
||||
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
if (!locales) {
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the given locale by ID.
|
||||
*
|
||||
* @param localeId the locale ID to search for.
|
||||
* @returns the corresponding {@link Locale} or `null` if it was not found.
|
||||
*/
|
||||
export function getLocaleById(localeId: string | null | undefined) {
|
||||
if (!localeId) return null;
|
||||
return locales?.find((l) => l.id === localeId) ?? null;
|
||||
}
|
||||
|
||||
export const t = i18next.t;
|
||||
export const getCurrentLanguage = () => i18next.language;
|
||||
@ -1,38 +0,0 @@
|
||||
import { t } from "./i18n.js";
|
||||
import toastService, { showError } from "./toast.js";
|
||||
|
||||
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
|
||||
try {
|
||||
$imageWrapper.attr("contenteditable", "true");
|
||||
selectImage($imageWrapper.get(0));
|
||||
|
||||
const success = document.execCommand("copy");
|
||||
|
||||
if (success) {
|
||||
toastService.showMessage(t("image.copied-to-clipboard"));
|
||||
} else {
|
||||
const message = t("image.cannot-copy");
|
||||
showError(message);
|
||||
logError(message);
|
||||
}
|
||||
} finally {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
$imageWrapper.removeAttr("contenteditable");
|
||||
}
|
||||
}
|
||||
|
||||
function selectImage(element: HTMLElement | undefined) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
export default {
|
||||
copyImageReferenceToClipboard
|
||||
};
|
||||
@ -1,118 +0,0 @@
|
||||
import toastService, { type ToastOptions } from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
type BooleanLike = boolean | "true" | "false";
|
||||
|
||||
export interface UploadFilesOptions {
|
||||
safeImport?: BooleanLike;
|
||||
shrinkImages: BooleanLike;
|
||||
textImportedAsText?: BooleanLike;
|
||||
codeImportedAsCode?: BooleanLike;
|
||||
explodeArchives?: BooleanLike;
|
||||
replaceUnderscoresWithSpaces?: BooleanLike;
|
||||
}
|
||||
|
||||
export async function uploadFiles(entityType: string, parentNoteId: string, files: string[] | File[], options: UploadFilesOptions) {
|
||||
if (!["notes", "attachments"].includes(entityType)) {
|
||||
throw new Error(`Unrecognized import entity type '${entityType}'.`);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
let counter = 0;
|
||||
|
||||
for (const file of files) {
|
||||
counter++;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("upload", file);
|
||||
formData.append("taskId", taskId);
|
||||
formData.append("last", counter === files.length ? "true" : "false");
|
||||
|
||||
for (const key in options) {
|
||||
formData.append(key, (options as any)[key]);
|
||||
}
|
||||
|
||||
await $.ajax({
|
||||
url: `${window.glob.baseApiUrl}notes/${parentNoteId}/${entityType}-import`,
|
||||
headers: await server.getHeaders(),
|
||||
data: formData,
|
||||
dataType: "json",
|
||||
type: "POST",
|
||||
timeout: 60 * 60 * 1000,
|
||||
error: function (xhr) {
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptions {
|
||||
return {
|
||||
id: id,
|
||||
title: t("import.import-status"),
|
||||
message: message,
|
||||
icon: "plus"
|
||||
};
|
||||
}
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importNotes") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("import.successful"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
if (message.result.importedNoteId) {
|
||||
await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.subscribeToMessages(async (message) => {
|
||||
if (message.taskType !== "importAttachments") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "taskError") {
|
||||
toastService.closePersistent(message.taskId);
|
||||
toastService.showError(message.message);
|
||||
} else if (message.type === "taskProgressCount") {
|
||||
toastService.showPersistent(makeToast(message.taskId, t("import.in-progress", { progress: message.progressCount })));
|
||||
} else if (message.type === "taskSucceeded") {
|
||||
const toast = makeToast(message.taskId, t("import.successful"));
|
||||
toast.closeAfter = 5000;
|
||||
|
||||
toastService.showPersistent(toast);
|
||||
|
||||
if (message.result.parentNoteId) {
|
||||
await appContext.tabManager.getActiveContext()?.setNote(message.result.importedNoteId, {
|
||||
viewScope: {
|
||||
viewMode: "attachments"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
uploadFiles
|
||||
};
|
||||
@ -1,121 +0,0 @@
|
||||
import server from "./server.js";
|
||||
import appContext, { type CommandNames } from "../components/app_context.js";
|
||||
import shortcutService from "./shortcuts.js";
|
||||
import type Component from "../components/component.js";
|
||||
|
||||
const keyboardActionRepo: Record<string, Action> = {};
|
||||
|
||||
// TODO: Deduplicate with server.
|
||||
export interface Action {
|
||||
actionName: CommandNames;
|
||||
effectiveShortcuts: string[];
|
||||
scope: string;
|
||||
}
|
||||
|
||||
const keyboardActionsLoaded = server.get<Action[]>("keyboard-actions").then((actions) => {
|
||||
actions = actions.filter((a) => !!a.actionName); // filter out separators
|
||||
|
||||
for (const action of actions) {
|
||||
action.effectiveShortcuts = action.effectiveShortcuts.filter((shortcut) => !shortcut.startsWith("global:"));
|
||||
|
||||
keyboardActionRepo[action.actionName] = action;
|
||||
}
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
async function getActions() {
|
||||
return await keyboardActionsLoaded;
|
||||
}
|
||||
|
||||
async function getActionsForScope(scope: string) {
|
||||
const actions = await keyboardActionsLoaded;
|
||||
|
||||
return actions.filter((action) => action.scope === scope);
|
||||
}
|
||||
|
||||
async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, component: Component) {
|
||||
const actions = await getActionsForScope(scope);
|
||||
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
shortcutService.bindElShortcut($el, shortcut, () => component.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getActionsForScope("window").then((actions) => {
|
||||
for (const action of actions) {
|
||||
for (const shortcut of action.effectiveShortcuts) {
|
||||
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function getAction(actionName: string, silent = false) {
|
||||
await keyboardActionsLoaded;
|
||||
|
||||
const action = keyboardActionRepo[actionName];
|
||||
|
||||
if (!action) {
|
||||
if (silent) {
|
||||
console.debug(`Cannot find action '${actionName}'`);
|
||||
} else {
|
||||
throw new Error(`Cannot find action '${actionName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
function updateDisplayedShortcuts($container: JQuery<HTMLElement>) {
|
||||
//@ts-ignore
|
||||
//TODO: each() does not support async callbacks.
|
||||
$container.find("kbd[data-command]").each(async (i, el) => {
|
||||
const actionName = $(el).attr("data-command");
|
||||
if (!actionName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = await getAction(actionName, true);
|
||||
|
||||
if (action) {
|
||||
const keyboardActions = action.effectiveShortcuts.join(", ");
|
||||
|
||||
if (keyboardActions || $(el).text() !== "not set") {
|
||||
$(el).text(keyboardActions);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//@ts-ignore
|
||||
//TODO: each() does not support async callbacks.
|
||||
$container.find("[data-trigger-command]").each(async (i, el) => {
|
||||
const actionName = $(el).attr("data-trigger-command");
|
||||
if (!actionName) {
|
||||
return;
|
||||
}
|
||||
const action = await getAction(actionName, true);
|
||||
|
||||
if (action) {
|
||||
const title = $(el).attr("title");
|
||||
const shortcuts = action.effectiveShortcuts.join(", ");
|
||||
|
||||
if (title?.includes(shortcuts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTitle = !title?.trim() ? shortcuts : `${title} (${shortcuts})`;
|
||||
|
||||
$(el).attr("title", newTitle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
updateDisplayedShortcuts,
|
||||
setupActionsForElement,
|
||||
getAction,
|
||||
getActions,
|
||||
getActionsForScope
|
||||
};
|
||||
@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseNavigationStateFromUrl } from "./link.js";
|
||||
|
||||
describe("Link", () => {
|
||||
it("parses plain searchString", () => {
|
||||
const output = parseNavigationStateFromUrl("http://localhost:8080/#?searchString=hello");
|
||||
expect(output).toMatchObject({ searchString: "hello" });
|
||||
});
|
||||
|
||||
it("parses searchString with hash", () => {
|
||||
const output = parseNavigationStateFromUrl("https://github.com/orgs/TriliumNext/discussions/1526#discussioncomment-12656660");
|
||||
expect(output).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("parses notePath", () => {
|
||||
const output = parseNavigationStateFromUrl(`#root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
|
||||
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
|
||||
});
|
||||
|
||||
it("parses notePath with spaces", () => {
|
||||
const output = parseNavigationStateFromUrl(` #root/WWaBNf3SSA1b/mQ2tIzLVFKHL`);
|
||||
expect(output).toMatchObject({ notePath: "root/WWaBNf3SSA1b/mQ2tIzLVFKHL", noteId: "mQ2tIzLVFKHL" });
|
||||
});
|
||||
|
||||
it("parses notePath with extraWindow", () => {
|
||||
const output = parseNavigationStateFromUrl(`127.0.0.1:8080/?extraWindow=1#root/QZGqKB7wVZF8?ntxId=0XPvXG`);
|
||||
expect(output).toMatchObject({ notePath: "root/QZGqKB7wVZF8", noteId: "QZGqKB7wVZF8" });
|
||||
});
|
||||
|
||||
it("ignores external URL with internal hash anchor", () => {
|
||||
const output = parseNavigationStateFromUrl(`https://en.wikipedia.org/wiki/Bearded_Collie#Health`);
|
||||
expect(output).toMatchObject({});
|
||||
});
|
||||
|
||||
it("ignores malformed but hash-containing external URL", () => {
|
||||
const output = parseNavigationStateFromUrl("https://abc.com/#drop?searchString=firefox");
|
||||
expect(output).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("ignores non-hash internal path", () => {
|
||||
const output = parseNavigationStateFromUrl("/root/abc123");
|
||||
expect(output).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
import katex from "katex";
|
||||
import "katex/contrib/mhchem";
|
||||
import "katex/dist/katex.min.css";
|
||||
export { default as renderMathInElement } from "katex/contrib/auto-render";
|
||||
export default katex;
|
||||
@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { postprocessMermaidSvg } from "./mermaid.js";
|
||||
import { trimIndentation } from "@triliumnext/commons";
|
||||
|
||||
describe("Mermaid", () => {
|
||||
it("converts <br> properly", () => {
|
||||
const before = trimIndentation`\
|
||||
<g transform="translate(-55.71875, -24)" style="color:black !important" class="label">
|
||||
<rect></rect>
|
||||
<foreignObject height="48" width="111.4375">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"
|
||||
style="color: black !important; display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;">
|
||||
<span class="nodeLabel" style="color:black !important">
|
||||
<p>Verify Output<br>Against<BR > Criteria</p>
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
`;
|
||||
const after = trimIndentation`\
|
||||
<g transform="translate(-55.71875, -24)" style="color:black !important" class="label">
|
||||
<rect></rect>
|
||||
<foreignObject height="48" width="111.4375">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"
|
||||
style="color: black !important; display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;">
|
||||
<span class="nodeLabel" style="color:black !important">
|
||||
<p>Verify Output<br/>Against<br/> Criteria</p>
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
`;
|
||||
expect(postprocessMermaidSvg(before)).toBe(after);
|
||||
});
|
||||
});
|
||||
@ -1,59 +0,0 @@
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
import type { Mermaid } from "mermaid";
|
||||
|
||||
let elkLoaded = false;
|
||||
|
||||
export function getMermaidConfig(): MermaidConfig {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
const mermaidTheme = documentStyle.getPropertyValue("--mermaid-theme") as "default";
|
||||
|
||||
return {
|
||||
theme: mermaidTheme.trim() as "default",
|
||||
securityLevel: "antiscript",
|
||||
flowchart: { useMaxWidth: false },
|
||||
sequence: { useMaxWidth: false },
|
||||
gantt: { useMaxWidth: false },
|
||||
class: { useMaxWidth: false },
|
||||
state: { useMaxWidth: false },
|
||||
pie: { useMaxWidth: true },
|
||||
journey: { useMaxWidth: false },
|
||||
gitGraph: { useMaxWidth: false }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the ELK extension of Mermaid.js needs to be loaded (which is a relatively large library), based on the
|
||||
* front-matter of the diagram and loads the library if needed.
|
||||
*
|
||||
* <p>
|
||||
* If the library has already been loaded or the diagram does not require it, the method will exit immediately.
|
||||
*
|
||||
* @param mermaidContent the plain text of the mermaid diagram, potentially including a frontmatter.
|
||||
*/
|
||||
export async function loadElkIfNeeded(mermaid: Mermaid, mermaidContent: string) {
|
||||
if (elkLoaded) {
|
||||
// Exit immediately since the ELK library is already loaded.
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedContent = await mermaid.parse(mermaidContent, {
|
||||
suppressErrors: true
|
||||
});
|
||||
if (parsedContent && parsedContent.config?.layout === "elk") {
|
||||
elkLoaded = true;
|
||||
mermaid.registerLayoutLoaders((await import("@mermaid-js/layout-elk")).default);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the output of a Mermaid SVG render before it should be delivered to the user.
|
||||
*
|
||||
* <p>
|
||||
* Currently this fixes <br> to <br/> which would otherwise cause an invalid XML.
|
||||
*
|
||||
* @param svg the Mermaid SVG to process.
|
||||
* @returns the processed SVG.
|
||||
*/
|
||||
export function postprocessMermaidSvg(svg: string) {
|
||||
return svg.replaceAll(/<br\s*>/ig, "<br/>");
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
import treeService from "./tree.js";
|
||||
import linkService from "./link.js";
|
||||
import froca from "./froca.js";
|
||||
import utils from "./utils.js";
|
||||
import attributeRenderer from "./attribute_renderer.js";
|
||||
import contentRenderer from "./content_renderer.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
// Track all elements that open tooltips
|
||||
let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
let dismissTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function setupGlobalTooltip() {
|
||||
$(document).on("mouseenter", "a", mouseEnterHandler);
|
||||
|
||||
// close any note tooltip after click, this fixes the problem that sometimes tooltips remained on the screen
|
||||
$(document).on("click", (e) => {
|
||||
if ($(e.target).closest(".note-tooltip").length) {
|
||||
// click within the tooltip shouldn't close it
|
||||
return;
|
||||
}
|
||||
|
||||
dismissAllTooltips();
|
||||
});
|
||||
}
|
||||
|
||||
function dismissAllTooltips() {
|
||||
clearTimeout(dismissTimer);
|
||||
openTooltipElements.forEach($el => {
|
||||
$el.tooltip("dispose");
|
||||
$el.removeAttr("aria-describedby");
|
||||
});
|
||||
openTooltipElements = [];
|
||||
}
|
||||
|
||||
function setupElementTooltip($el: JQuery<HTMLElement>) {
|
||||
$el.on("mouseenter", mouseEnterHandler);
|
||||
}
|
||||
|
||||
async function mouseEnterHandler(this: HTMLElement) {
|
||||
const $link = $(this);
|
||||
|
||||
if ($link.hasClass("no-tooltip-preview") || $link.hasClass("disabled")) {
|
||||
return;
|
||||
} else if ($link.closest(".ck-link-actions").length) {
|
||||
// this is to avoid showing tooltip from inside the CKEditor link editor dialog
|
||||
return;
|
||||
} else if ($link.closest(".note-tooltip").length) {
|
||||
// don't show tooltip for links within tooltip
|
||||
return;
|
||||
}
|
||||
|
||||
const url = $link.attr("href") || $link.attr("data-href");
|
||||
const { notePath, noteId, viewScope } = linkService.parseNavigationStateFromUrl(url);
|
||||
|
||||
if (url?.startsWith("#fnref")) {
|
||||
// The "^" symbol from footnotes within text notes, doesn't require a tooltip.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!notePath || !noteId || viewScope?.viewMode !== "default") {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkId = $link.attr("data-link-id") || `link-${Math.floor(Math.random() * 1000000)}`;
|
||||
$link.attr("data-link-id", linkId);
|
||||
|
||||
if ($(`.${linkId}`).is(":visible")) {
|
||||
// tooltip is already open for this link
|
||||
return;
|
||||
}
|
||||
|
||||
let renderPromise;
|
||||
if (url && url.startsWith("#") && !url.startsWith("#root/")) {
|
||||
renderPromise = renderFootnoteOrAnchor($link, url);
|
||||
} else {
|
||||
renderPromise = renderTooltip(await froca.getNote(noteId));
|
||||
}
|
||||
|
||||
const [content] = await Promise.all([
|
||||
renderPromise,
|
||||
// to reduce flicker due to accidental mouseover, cursor must stay for a bit over the link for tooltip to appear
|
||||
new Promise((res) => setTimeout(res, 500))
|
||||
]);
|
||||
|
||||
if (!content || utils.isHtmlEmpty(content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `<div class="note-tooltip-content">${content}</div>`;
|
||||
const tooltipClass = "tooltip-" + Math.floor(Math.random() * 999_999_999);
|
||||
|
||||
// we need to check if we're still hovering over the element
|
||||
// since the operation to get tooltip content was async, it is possible that
|
||||
// we now create tooltip which won't close because it won't receive mouseleave event
|
||||
if ($link.filter(":hover").length > 0) {
|
||||
$link.tooltip({
|
||||
container: "body",
|
||||
// https://github.com/zadam/trilium/issues/2794 https://github.com/zadam/trilium/issues/2988
|
||||
// with bottom this flickering happens a bit less
|
||||
placement: "bottom",
|
||||
trigger: "manual",
|
||||
//TODO: boundary No longer applicable?
|
||||
//boundary: 'window',
|
||||
title: html,
|
||||
html: true,
|
||||
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
|
||||
sanitize: false,
|
||||
customClass: linkId
|
||||
});
|
||||
|
||||
dismissAllTooltips();
|
||||
$link.tooltip("show");
|
||||
|
||||
openTooltipElements.push($link);
|
||||
|
||||
// Dismiss the tooltip immediately if a link was clicked inside the tooltip.
|
||||
$(`.${tooltipClass} a`).on("click", (e) => {
|
||||
dismissAllTooltips();
|
||||
});
|
||||
|
||||
// the purpose of the code below is to:
|
||||
// - allow user to go from hovering the link to hovering the tooltip to be able to scroll,
|
||||
// click on links within tooltip etc. without tooltip disappearing
|
||||
// - once the user moves the cursor away from both link and the tooltip, hide the tooltip
|
||||
const checkTooltip = () => {
|
||||
|
||||
if (!$link.filter(":hover").length && !$(`.${linkId}:hover`).length) {
|
||||
// cursor is neither over the link nor over the tooltip, user likely is not interested
|
||||
dismissAllTooltips();
|
||||
} else {
|
||||
dismissTimer = setTimeout(checkTooltip, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
dismissTimer = setTimeout(checkTooltip, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderTooltip(note: FNote | null) {
|
||||
if (!note) {
|
||||
return `<div>${t("note_tooltip.note-has-been-deleted")}</div>`;
|
||||
}
|
||||
|
||||
const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId;
|
||||
const bestNotePath = note.getBestNotePathString(hoistedNoteId);
|
||||
|
||||
if (!bestNotePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const noteTitleWithPathAsSuffix = await treeService.getNoteTitleWithPathAsSuffix(bestNotePath);
|
||||
|
||||
const { $renderedAttributes } = await attributeRenderer.renderNormalAttributes(note);
|
||||
|
||||
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
|
||||
tooltip: true,
|
||||
trim: true
|
||||
});
|
||||
const isContentEmpty = $renderedContent[0].innerHTML.length === 0;
|
||||
|
||||
let content = "";
|
||||
if (noteTitleWithPathAsSuffix) {
|
||||
const classes = ["note-tooltip-title"];
|
||||
if (isContentEmpty) {
|
||||
classes.push("note-no-content");
|
||||
}
|
||||
content = `<h5 class="${classes.join(" ")}"><a href="#${note.noteId}" data-no-context-menu="true">${noteTitleWithPathAsSuffix.prop("outerHTML")}</a></h5>`;
|
||||
}
|
||||
|
||||
content = `${content}<div class="note-tooltip-attributes">${$renderedAttributes[0].outerHTML}</div>`;
|
||||
if (!isContentEmpty) {
|
||||
content += $renderedContent[0].outerHTML;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function renderFootnoteOrAnchor($link: JQuery<HTMLElement>, url: string) {
|
||||
// A footnote text reference
|
||||
const footnoteRef = url.substring(3);
|
||||
let $targetContent: JQuery<HTMLElement>;
|
||||
|
||||
if (url.startsWith("#fn")) {
|
||||
$targetContent = $link
|
||||
.closest(".ck-content") // find the parent CK content
|
||||
.find("> .footnote-section") // find the footnote section
|
||||
.find(`a[href="#fnref${footnoteRef}"]`) // find the footnote link
|
||||
.closest(".footnote-item") // find the parent container of the footnote
|
||||
.find(".footnote-content"); // find the actual text content of the footnote
|
||||
} else {
|
||||
$targetContent = $link
|
||||
.closest(".ck-content")
|
||||
.find(url)
|
||||
.closest("p");
|
||||
}
|
||||
|
||||
if (!$targetContent.length) {
|
||||
// If the target content is not found, return an empty string
|
||||
return "";
|
||||
}
|
||||
|
||||
const isEditable = $link.closest(".ck-content").hasClass("note-detail-editable-text-editor");
|
||||
if (isEditable) {
|
||||
/* Remove widget buttons for tables, formulas, and images in editable notes. */
|
||||
$targetContent.find('.ck-widget__selection-handle').remove();
|
||||
$targetContent.find('.ck-widget__type-around').remove();
|
||||
$targetContent.find('.ck-widget__resizer').remove();
|
||||
|
||||
/* Handling in-line math formulas */
|
||||
$targetContent.find('.ck-math-tex.ck-math-tex-inline.ck-widget').each(function () {
|
||||
const $katex = $(this).find('.katex').first();
|
||||
if ($katex.length) {
|
||||
$(this).replaceWith($('<span class="math-tex"></span>').append($('<span></span>').append($katex.clone())));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let footnoteContent = $targetContent.html();
|
||||
footnoteContent = `<div class="ck-content">${footnoteContent}</div>`
|
||||
return footnoteContent || "";
|
||||
}
|
||||
|
||||
export default {
|
||||
setupGlobalTooltip,
|
||||
setupElementTooltip,
|
||||
dismissAllTooltips
|
||||
};
|
||||
@ -1,81 +0,0 @@
|
||||
import server from "./server.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { MenuItem } from "../menus/context_menu.js";
|
||||
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
|
||||
|
||||
const SEPARATOR = { title: "----" };
|
||||
|
||||
async function getNoteTypeItems(command?: TreeCommandNames) {
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" },
|
||||
{ title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" },
|
||||
{ title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" },
|
||||
{ title: t("note_types.relation-map"), command, type: "relationMap", uiIcon: "bx bxs-network-chart" },
|
||||
{ title: t("note_types.note-map"), command, type: "noteMap", uiIcon: "bx bxs-network-chart" },
|
||||
{ title: t("note_types.render-note"), command, type: "render", uiIcon: "bx bx-extension" },
|
||||
{ title: t("note_types.book"), command, type: "book", uiIcon: "bx bx-book" },
|
||||
{ title: t("note_types.mermaid-diagram"), command, type: "mermaid", uiIcon: "bx bx-selection" },
|
||||
{ title: t("note_types.canvas"), command, type: "canvas", uiIcon: "bx bx-pen" },
|
||||
{ title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" },
|
||||
{ title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
|
||||
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" },
|
||||
...await getBuiltInTemplates(command),
|
||||
...await getUserTemplates(command)
|
||||
];
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getUserTemplates(command?: TreeCommandNames) {
|
||||
const templateNoteIds = await server.get<string[]>("search-templates");
|
||||
const templateNotes = await froca.getNotes(templateNoteIds);
|
||||
if (templateNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
SEPARATOR
|
||||
];
|
||||
for (const templateNote of templateNotes) {
|
||||
items.push({
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command: command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function getBuiltInTemplates(command?: TreeCommandNames) {
|
||||
const templatesRoot = await froca.getNote("_templates");
|
||||
if (!templatesRoot) {
|
||||
console.warn("Unable to find template root.");
|
||||
return [];
|
||||
}
|
||||
|
||||
const childNotes = await templatesRoot.getChildNotes();
|
||||
if (childNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: MenuItem<TreeCommandNames>[] = [
|
||||
SEPARATOR
|
||||
];
|
||||
for (const templateNote of childNotes) {
|
||||
items.push({
|
||||
title: templateNote.title,
|
||||
uiIcon: templateNote.getIcon(),
|
||||
command: command,
|
||||
type: templateNote.type,
|
||||
templateNoteId: templateNote.noteId
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export default {
|
||||
getNoteTypeItems
|
||||
};
|
||||
@ -1,86 +0,0 @@
|
||||
import server from "./server.js";
|
||||
import { isShare } from "./utils.js";
|
||||
|
||||
type OptionValue = number | string;
|
||||
|
||||
class Options {
|
||||
initializedPromise: Promise<void>;
|
||||
private arr!: Record<string, OptionValue>;
|
||||
|
||||
constructor() {
|
||||
if (!isShare) {
|
||||
this.initializedPromise = server.get<Record<string, OptionValue>>("options").then((data) => this.load(data));
|
||||
} else {
|
||||
this.initializedPromise = Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
load(arr: Record<string, OptionValue>) {
|
||||
this.arr = arr;
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.arr?.[key] as string;
|
||||
}
|
||||
|
||||
getNames() {
|
||||
return Object.keys(this.arr || []);
|
||||
}
|
||||
|
||||
getJson(key: string) {
|
||||
const value = this.arr?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getInt(key: string) {
|
||||
const value = this.arr?.[key];
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value == "string") {
|
||||
return parseInt(value);
|
||||
}
|
||||
console.warn("Attempting to read int for unsupported value: ", value);
|
||||
return null;
|
||||
}
|
||||
|
||||
getFloat(key: string) {
|
||||
const value = this.arr?.[key];
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
is(key: string) {
|
||||
return this.arr[key] === "true";
|
||||
}
|
||||
|
||||
set(key: string, value: OptionValue) {
|
||||
this.arr[key] = value;
|
||||
}
|
||||
|
||||
async save(key: string, value: OptionValue) {
|
||||
this.set(key, value);
|
||||
|
||||
const payload: Record<string, OptionValue> = {};
|
||||
payload[key] = value;
|
||||
|
||||
await server.put(`options`, payload);
|
||||
}
|
||||
|
||||
async toggle(key: string) {
|
||||
await this.save(key, (!this.is(key)).toString());
|
||||
}
|
||||
}
|
||||
|
||||
const options = new Options();
|
||||
|
||||
export default options;
|
||||
@ -1,89 +0,0 @@
|
||||
import options from "./options.js";
|
||||
import Split from "split.js"
|
||||
|
||||
export const DEFAULT_GUTTER_SIZE = 5;
|
||||
|
||||
let leftPaneWidth: number;
|
||||
let reservedPx: number;
|
||||
let layoutOrientation: string;
|
||||
let leftInstance: ReturnType<typeof Split> | null;
|
||||
let rightPaneWidth: number;
|
||||
let rightInstance: ReturnType<typeof Split> | null;
|
||||
|
||||
function setupLeftPaneResizer(leftPaneVisible: boolean) {
|
||||
if (leftInstance) {
|
||||
leftInstance.destroy();
|
||||
leftInstance = null;
|
||||
}
|
||||
|
||||
$("#left-pane").toggle(leftPaneVisible);
|
||||
|
||||
layoutOrientation = layoutOrientation ?? options.get("layoutOrientation");
|
||||
reservedPx = reservedPx ?? (layoutOrientation === "vertical" ? ($("#launcher-pane").outerWidth() || 0) : 0);
|
||||
// Window resizing causes `window.innerWidth` to change, so `reservedWidth` needs to be recalculated each time.
|
||||
const reservedWidth = reservedPx / window.innerWidth * 100;
|
||||
if (!leftPaneVisible) {
|
||||
$("#rest-pane").css("width", layoutOrientation === "vertical" ? `${100 - reservedWidth}%` : "100%");
|
||||
return;
|
||||
}
|
||||
|
||||
leftPaneWidth = leftPaneWidth ?? (options.getInt("leftPaneWidth") ?? 0);
|
||||
if (!leftPaneWidth || leftPaneWidth < 5) {
|
||||
leftPaneWidth = 5;
|
||||
}
|
||||
|
||||
const restPaneWidth = 100 - leftPaneWidth - reservedWidth;
|
||||
if (leftPaneVisible) {
|
||||
// Delayed initialization ensures that all DOM elements are fully rendered and part of the layout,
|
||||
// preventing Split.js from retrieving incorrect dimensions due to #left-pane not being rendered yet,
|
||||
// which would cause the minSize setting to have no effect.
|
||||
requestAnimationFrame(() => {
|
||||
leftInstance = Split(["#left-pane", "#rest-pane"], {
|
||||
sizes: [leftPaneWidth, restPaneWidth],
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
minSize: [150, 300],
|
||||
onDragEnd: (sizes) => {
|
||||
leftPaneWidth = Math.round(sizes[0]);
|
||||
options.save("leftPaneWidth", Math.round(sizes[0]));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupRightPaneResizer() {
|
||||
if (rightInstance) {
|
||||
rightInstance.destroy();
|
||||
rightInstance = null;
|
||||
}
|
||||
|
||||
const rightPaneVisible = $("#right-pane").is(":visible");
|
||||
|
||||
if (!rightPaneVisible) {
|
||||
$("#center-pane").css("width", "100%");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
rightPaneWidth = rightPaneWidth ?? (options.getInt("rightPaneWidth") ?? 0);
|
||||
if (!rightPaneWidth || rightPaneWidth < 5) {
|
||||
rightPaneWidth = 5;
|
||||
}
|
||||
|
||||
if (rightPaneVisible) {
|
||||
rightInstance = Split(["#center-pane", "#right-pane"], {
|
||||
sizes: [100 - rightPaneWidth, rightPaneWidth],
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
minSize: [300, 180],
|
||||
onDragEnd: (sizes) => {
|
||||
rightPaneWidth = Math.round(sizes[1]);
|
||||
options.save("rightPaneWidth", Math.round(sizes[1]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
setupLeftPaneResizer,
|
||||
setupRightPaneResizer
|
||||
};
|
||||
@ -1,57 +0,0 @@
|
||||
import utils from "./utils.js";
|
||||
|
||||
type ElementType = HTMLElement | Document;
|
||||
type Handler = (e: JQuery.TriggeredEvent<ElementType | Element, string, ElementType | Element, ElementType | Element>) => void;
|
||||
|
||||
function removeGlobalShortcut(namespace: string) {
|
||||
bindGlobalShortcut("", null, namespace);
|
||||
}
|
||||
|
||||
function bindGlobalShortcut(keyboardShortcut: string, handler: Handler | null, namespace: string | null = null) {
|
||||
bindElShortcut($(document), keyboardShortcut, handler, namespace);
|
||||
}
|
||||
|
||||
function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: string, handler: Handler | null, namespace: string | null = null) {
|
||||
if (utils.isDesktop()) {
|
||||
keyboardShortcut = normalizeShortcut(keyboardShortcut);
|
||||
|
||||
let eventName = "keydown";
|
||||
|
||||
if (namespace) {
|
||||
eventName += `.${namespace}`;
|
||||
|
||||
// if there's a namespace, then we replace the existing event handler with the new one
|
||||
$el.off(eventName);
|
||||
}
|
||||
|
||||
// method can be called to remove the shortcut (e.g. when keyboardShortcut label is deleted)
|
||||
if (keyboardShortcut) {
|
||||
$el.bind(eventName, keyboardShortcut, (e) => {
|
||||
if (handler) {
|
||||
handler(e);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize to the form expected by the jquery.hotkeys.js
|
||||
*/
|
||||
function normalizeShortcut(shortcut: string): string {
|
||||
if (!shortcut) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
return shortcut.toLowerCase().replace("enter", "return").replace("delete", "del").replace("ctrl+alt", "alt+ctrl").replace("meta+alt", "alt+meta"); // alt needs to be first;
|
||||
}
|
||||
|
||||
export default {
|
||||
bindGlobalShortcut,
|
||||
bindElShortcut,
|
||||
removeGlobalShortcut,
|
||||
normalizeShortcut
|
||||
};
|
||||
@ -1,140 +0,0 @@
|
||||
import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type AutoHighlightResult, type HighlightResult, type Theme } from "@triliumnext/highlightjs";
|
||||
import mime_types from "./mime_types.js";
|
||||
import options from "./options.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
|
||||
import { isShare } from "./utils.js";
|
||||
import { MimeType } from "@triliumnext/commons";
|
||||
|
||||
let highlightingLoaded = false;
|
||||
|
||||
/**
|
||||
* Identifies all the code blocks (as `pre code`) under the specified hierarchy and uses the highlight.js library to obtain the highlighted text which is then applied on to the code blocks.
|
||||
* Additionally, adds a "Copy to clipboard" button.
|
||||
*
|
||||
* @param $container the container under which to look for code blocks and to apply syntax highlighting to them.
|
||||
*/
|
||||
export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
|
||||
const syntaxHighlightingEnabled = isSyntaxHighlightEnabled();
|
||||
|
||||
const codeBlocks = $container.find("pre code");
|
||||
for (const codeBlock of codeBlocks) {
|
||||
const normalizedMimeType = extractLanguageFromClassList(codeBlock);
|
||||
if (!normalizedMimeType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
applyCopyToClipboardButton($(codeBlock));
|
||||
|
||||
if (syntaxHighlightingEnabled) {
|
||||
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
|
||||
const $copyButton = $("<button>")
|
||||
.addClass("bx component icon-action tn-tool-button bx-copy copy-button")
|
||||
.attr("title", t("code_block.copy_title"))
|
||||
.on("click", () => {
|
||||
if (!isShare) {
|
||||
copyTextWithToast($codeBlock.text());
|
||||
} else {
|
||||
copyText($codeBlock.text());
|
||||
}
|
||||
});
|
||||
$codeBlock.parent().append($copyButton);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
|
||||
*/
|
||||
export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLElement>, normalizedMimeType: string) {
|
||||
$codeBlock.parent().toggleClass("hljs");
|
||||
const text = $codeBlock.text();
|
||||
|
||||
let highlightedText: HighlightResult | AutoHighlightResult | null = null;
|
||||
if (normalizedMimeType === mime_types.MIME_TYPE_AUTO && !isShare) {
|
||||
await ensureMimeTypesForHighlighting();
|
||||
highlightedText = highlightAuto(text);
|
||||
} else if (normalizedMimeType) {
|
||||
await ensureMimeTypesForHighlighting(normalizedMimeType);
|
||||
highlightedText = highlight(text, { language: normalizedMimeType });
|
||||
}
|
||||
|
||||
if (highlightedText) {
|
||||
$codeBlock.html(highlightedText.value);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
|
||||
if (highlightingLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load theme.
|
||||
const currentThemeName = String(options.get("codeBlockTheme"));
|
||||
loadHighlightingTheme(currentThemeName);
|
||||
|
||||
// Load mime types.
|
||||
let mimeTypes: MimeType[];
|
||||
|
||||
if (mimeTypeHint) {
|
||||
mimeTypes = [
|
||||
{
|
||||
title: mimeTypeHint,
|
||||
enabled: true,
|
||||
mime: mimeTypeHint.replace("-", "/")
|
||||
}
|
||||
]
|
||||
} else {
|
||||
mimeTypes = mime_types.getMimeTypes();
|
||||
}
|
||||
|
||||
await ensureMimeTypes(mimeTypes);
|
||||
|
||||
highlightingLoaded = true;
|
||||
}
|
||||
|
||||
export function loadHighlightingTheme(themeName: string) {
|
||||
const themePrefix = "default:";
|
||||
let theme: Theme | null = null;
|
||||
if (themeName.includes(themePrefix)) {
|
||||
theme = Themes[themeName.substring(themePrefix.length)];
|
||||
}
|
||||
if (!theme) {
|
||||
theme = Themes.default;
|
||||
}
|
||||
|
||||
loadTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether syntax highlighting should be enabled for code blocks, by querying the value of the `codeblockTheme` option.
|
||||
* @returns whether syntax highlighting should be enabled for code blocks.
|
||||
*/
|
||||
export function isSyntaxHighlightEnabled() {
|
||||
if (!isShare) {
|
||||
const theme = options.get("codeBlockTheme");
|
||||
return !!theme && theme !== "none";
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a HTML element, tries to extract the `language-` class name out of it.
|
||||
*
|
||||
* @param el the HTML element from which to extract the language tag.
|
||||
* @returns the normalized MIME type (e.g. `text-css` instead of `language-text-css`).
|
||||
*/
|
||||
function extractLanguageFromClassList(el: HTMLElement) {
|
||||
const prefix = "language-";
|
||||
for (const className of el.classList) {
|
||||
if (className.startsWith(prefix)) {
|
||||
return className.substring(prefix.length);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,326 +0,0 @@
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import froca from "./froca.js";
|
||||
import hoistedNoteService from "./hoisted_note.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
|
||||
/**
|
||||
* @returns {string|null}
|
||||
*/
|
||||
async function resolveNotePath(notePath: string, hoistedNoteId = "root") {
|
||||
const runPath = await resolveNotePathToSegments(notePath, hoistedNoteId);
|
||||
|
||||
return runPath ? runPath.join("/") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts notePath which might or might not be valid and returns an existing path as close to the original
|
||||
* notePath as possible. Part of the path might not be valid because of note moving (which causes
|
||||
* path change) or other corruption, in that case, this will try to get some other valid path to the correct note.
|
||||
*/
|
||||
async function resolveNotePathToSegments(notePath: string, hoistedNoteId = "root", logErrors = true) {
|
||||
utils.assertArguments(notePath);
|
||||
|
||||
// we might get notePath with the params suffix, remove it if present
|
||||
notePath = notePath.split("?")[0].trim();
|
||||
|
||||
if (notePath.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = notePath.split("/").reverse();
|
||||
|
||||
if (!path.includes("root")) {
|
||||
path.push("root");
|
||||
}
|
||||
|
||||
const effectivePathSegments: string[] = [];
|
||||
let childNoteId: string | null = null;
|
||||
let i = 0;
|
||||
|
||||
while (true) {
|
||||
if (i >= path.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentNoteId = path[i++];
|
||||
|
||||
if (childNoteId !== null) {
|
||||
const child = await froca.getNote(childNoteId, !logErrors);
|
||||
|
||||
if (!child) {
|
||||
if (logErrors) {
|
||||
ws.logError(`Can't find note ${childNoteId}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
child.sortParents();
|
||||
|
||||
const parents = child.getParentNotes();
|
||||
|
||||
if (!parents.length) {
|
||||
if (logErrors) {
|
||||
ws.logError(`No parents found for note ${childNoteId} (${child.title}) for path ${notePath}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parents.some((p) => p.noteId === parentNoteId)) {
|
||||
if (logErrors) {
|
||||
const parent = froca.getNoteFromCache(parentNoteId);
|
||||
|
||||
console.debug(
|
||||
utils.now(),
|
||||
`Did not find parent ${parentNoteId} (${parent ? parent.title : "n/a"})
|
||||
for child ${childNoteId} (${child.title}), available parents: ${parents.map((p) => `${p.noteId} (${p.title})`)}.
|
||||
You can ignore this message as it is mostly harmless.`
|
||||
);
|
||||
}
|
||||
|
||||
const bestNotePath = child.getBestNotePath(hoistedNoteId);
|
||||
|
||||
if (bestNotePath) {
|
||||
const pathToRoot = bestNotePath.reverse().slice(1);
|
||||
|
||||
for (const noteId of pathToRoot) {
|
||||
effectivePathSegments.push(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
effectivePathSegments.push(parentNoteId);
|
||||
childNoteId = parentNoteId;
|
||||
}
|
||||
|
||||
effectivePathSegments.reverse();
|
||||
|
||||
if (effectivePathSegments.includes(hoistedNoteId)) {
|
||||
return effectivePathSegments;
|
||||
} else {
|
||||
const noteId = getNoteIdFromUrl(notePath);
|
||||
if (!noteId) {
|
||||
throw new Error(`Unable to find note with ID: ${noteId}.`);
|
||||
}
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) {
|
||||
throw new Error(`Unable to find note: ${notePath}.`);
|
||||
}
|
||||
const bestNotePath = note.getBestNotePath(hoistedNoteId);
|
||||
|
||||
if (!bestNotePath) {
|
||||
throw new Error(`Did not find any path segments for '${note.toString()}', hoisted note '${hoistedNoteId}'`);
|
||||
}
|
||||
|
||||
// if there isn't actually any note path with hoisted note, then return the original resolved note path
|
||||
return bestNotePath.includes(hoistedNoteId) ? bestNotePath : effectivePathSegments;
|
||||
}
|
||||
}
|
||||
|
||||
ws.subscribeToMessages((message) => {
|
||||
if (message.type === "openNote") {
|
||||
appContext.tabManager.activateOrOpenNote(message.noteId);
|
||||
|
||||
if (utils.isElectron()) {
|
||||
const currentWindow = utils.dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
|
||||
currentWindow.show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getParentProtectedStatus(node: Fancytree.FancytreeNode) {
|
||||
return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected;
|
||||
}
|
||||
|
||||
function getNoteIdFromUrl(urlOrNotePath: string | null | undefined) {
|
||||
if (!urlOrNotePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [notePath] = urlOrNotePath.split("?");
|
||||
const segments = notePath.split("/");
|
||||
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
async function getBranchIdFromUrl(urlOrNotePath: string) {
|
||||
const { noteId, parentNoteId } = getNoteIdAndParentIdFromUrl(urlOrNotePath);
|
||||
if (!parentNoteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await froca.getBranchId(parentNoteId, noteId);
|
||||
}
|
||||
|
||||
function getNoteIdAndParentIdFromUrl(urlOrNotePath: string) {
|
||||
if (!urlOrNotePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [notePath] = urlOrNotePath.split("?");
|
||||
|
||||
if (notePath === "root") {
|
||||
return {
|
||||
noteId: "root",
|
||||
parentNoteId: "none"
|
||||
};
|
||||
}
|
||||
|
||||
let parentNoteId = "root";
|
||||
let noteId = "";
|
||||
|
||||
if (notePath) {
|
||||
const segments = notePath.split("/");
|
||||
|
||||
noteId = segments[segments.length - 1];
|
||||
|
||||
if (segments.length > 1) {
|
||||
parentNoteId = segments[segments.length - 2];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parentNoteId,
|
||||
noteId
|
||||
};
|
||||
}
|
||||
|
||||
function getNotePath(node: Fancytree.FancytreeNode) {
|
||||
if (!node) {
|
||||
logError("Node is null");
|
||||
return "";
|
||||
}
|
||||
|
||||
const path: string[] = [];
|
||||
|
||||
while (node) {
|
||||
if (node.data.noteId) {
|
||||
path.push(node.data.noteId);
|
||||
}
|
||||
|
||||
node = node.getParent();
|
||||
}
|
||||
|
||||
return path.reverse().join("/");
|
||||
}
|
||||
|
||||
async function getNoteTitle(noteId: string, parentNoteId: string | null = null) {
|
||||
utils.assertArguments(noteId);
|
||||
|
||||
const note = await froca.getNote(noteId);
|
||||
if (!note) {
|
||||
return "[not found]";
|
||||
}
|
||||
|
||||
let { title } = note;
|
||||
|
||||
if (parentNoteId !== null) {
|
||||
const branchId = note.parentToBranch[parentNoteId];
|
||||
|
||||
if (branchId) {
|
||||
const branch = froca.getBranch(branchId);
|
||||
|
||||
if (branch?.prefix) {
|
||||
title = `${branch.prefix} - ${title}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
async function getNotePathTitleComponents(notePath: string) {
|
||||
const titleComponents: string[] = [];
|
||||
|
||||
if (notePath.startsWith("root/")) {
|
||||
notePath = notePath.substr(5);
|
||||
}
|
||||
|
||||
// special case when we want just root's title
|
||||
if (notePath === "root") {
|
||||
titleComponents.push(await getNoteTitle(notePath));
|
||||
} else {
|
||||
let parentNoteId = "root";
|
||||
|
||||
for (const noteId of notePath.split("/")) {
|
||||
titleComponents.push(await getNoteTitle(noteId, parentNoteId));
|
||||
|
||||
parentNoteId = noteId;
|
||||
}
|
||||
}
|
||||
|
||||
return titleComponents;
|
||||
}
|
||||
|
||||
async function getNotePathTitle(notePath: string) {
|
||||
utils.assertArguments(notePath);
|
||||
|
||||
const titlePath = await getNotePathTitleComponents(notePath);
|
||||
|
||||
return titlePath.join(" / ");
|
||||
}
|
||||
|
||||
async function getNoteTitleWithPathAsSuffix(notePath: string) {
|
||||
utils.assertArguments(notePath);
|
||||
|
||||
const titleComponents = await getNotePathTitleComponents(notePath);
|
||||
|
||||
if (!titleComponents || titleComponents.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const title = titleComponents[titleComponents.length - 1];
|
||||
const path = titleComponents.slice(0, titleComponents.length - 1);
|
||||
|
||||
const $titleWithPath = $('<span class="note-title-with-path">').append($('<span class="note-title">').text(title));
|
||||
|
||||
$titleWithPath.append(formatNotePath(path));
|
||||
|
||||
return $titleWithPath;
|
||||
}
|
||||
|
||||
function formatNotePath(path: string[]) {
|
||||
const $notePath = $('<span class="note-path">');
|
||||
|
||||
if (path.length > 0) {
|
||||
$notePath.append($(`<span class="path-bracket"> (</span>)`));
|
||||
|
||||
for (let segmentIndex = 0; segmentIndex < path.length; segmentIndex++) {
|
||||
$notePath.append($(`<span>`).text(path[segmentIndex]));
|
||||
|
||||
if (segmentIndex < path.length - 1) {
|
||||
$notePath.append($(`<span class="path-delimiter">`).text(" / "));
|
||||
}
|
||||
}
|
||||
|
||||
$notePath.append($(`<span class="path-bracket">)</span>)`));
|
||||
}
|
||||
|
||||
return $notePath;
|
||||
}
|
||||
|
||||
function isNotePathInHiddenSubtree(notePath: string) {
|
||||
return notePath?.includes("root/_hidden");
|
||||
}
|
||||
|
||||
export default {
|
||||
resolveNotePath,
|
||||
resolveNotePathToSegments,
|
||||
getParentProtectedStatus,
|
||||
getNotePath,
|
||||
getNotePathTitleComponents,
|
||||
getNoteIdFromUrl,
|
||||
getNoteIdAndParentIdFromUrl,
|
||||
getBranchIdFromUrl,
|
||||
getNoteTitle,
|
||||
getNotePathTitle,
|
||||
getNoteTitleWithPathAsSuffix,
|
||||
isNotePathInHiddenSubtree,
|
||||
formatNotePath
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getSizeFromSvg } from "./utils.js";
|
||||
|
||||
describe("getSizeFromSvg", () => {
|
||||
it("parses width & height attribute", () => {
|
||||
const svg = `<svg aria-roledescription="sequence" role="graphics-document document" viewBox="-50 -10 714 574" height="574" xmlns="http://www.w3.org/2000/svg" width="714" id="mermaid-graph-2"></svg>`;
|
||||
const result = getSizeFromSvg(svg);
|
||||
expect(result).toMatchObject({
|
||||
width: 714,
|
||||
height: 574,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses viewbox", () => {
|
||||
const svg = `<svg aria-roledescription="er" role="graphics-document document" viewBox="0 0 872.2750244140625 655" style="max-width: 872.2750244140625px;" class="erDiagram" xmlns="http://www.w3.org/2000/svg" width="100%" id="mermaid-graph-2">`;
|
||||
const result = getSizeFromSvg(svg);
|
||||
expect(result).toMatchObject({
|
||||
width: 872.2750244140625,
|
||||
height: 655
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,782 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { ViewScope } from "./link.js";
|
||||
|
||||
const SVG_MIME = "image/svg+xml";
|
||||
|
||||
export const isShare = !window.glob;
|
||||
|
||||
function reloadFrontendApp(reason?: string) {
|
||||
if (reason) {
|
||||
logInfo(`Frontend app reload: ${reason}`);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function restartDesktopApp() {
|
||||
if (!isElectron()) {
|
||||
reloadFrontendApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const app = dynamicRequire("@electron/remote").app;
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the system tray to update its menu items, i.e. after a change in dynamic content such as bookmarks or recent notes.
|
||||
*
|
||||
* On any other platform than Electron, nothing happens.
|
||||
*/
|
||||
function reloadTray() {
|
||||
if (!isElectron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ipcRenderer } = dynamicRequire("electron");
|
||||
ipcRenderer.send("reload-tray");
|
||||
}
|
||||
|
||||
function parseDate(str: string) {
|
||||
try {
|
||||
return new Date(Date.parse(str));
|
||||
} catch (e: any) {
|
||||
throw new Error(`Can't parse date from '${str}': ${e.message} ${e.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Source: https://stackoverflow.com/a/30465299/4898894
|
||||
function getMonthsInDateRange(startDate: string, endDate: string) {
|
||||
const start = startDate.split("-");
|
||||
const end = endDate.split("-");
|
||||
const startYear = parseInt(start[0]);
|
||||
const endYear = parseInt(end[0]);
|
||||
const dates: string[] = [];
|
||||
|
||||
for (let i = startYear; i <= endYear; i++) {
|
||||
const endMonth = i != endYear ? 11 : parseInt(end[1]) - 1;
|
||||
const startMon = i === startYear ? parseInt(start[1]) - 1 : 0;
|
||||
|
||||
for (let j = startMon; j <= endMonth; j = j > 12 ? j % 12 || 11 : j + 1) {
|
||||
const month = j + 1;
|
||||
const displayMonth = month < 10 ? "0" + month : month;
|
||||
dates.push([i, displayMonth].join("-"));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function padNum(num: number) {
|
||||
return `${num <= 9 ? "0" : ""}${num}`;
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return `${padNum(date.getHours())}:${padNum(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatTimeWithSeconds(date: Date) {
|
||||
return `${padNum(date.getHours())}:${padNum(date.getMinutes())}:${padNum(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatTimeInterval(ms: number) {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const plural = (count: number, name: string) => `${count} ${name}${count > 1 ? "s" : ""}`;
|
||||
const segments: string[] = [];
|
||||
|
||||
if (days > 0) {
|
||||
segments.push(plural(days, "day"));
|
||||
}
|
||||
|
||||
if (days < 2) {
|
||||
if (hours % 24 > 0) {
|
||||
segments.push(plural(hours % 24, "hour"));
|
||||
}
|
||||
|
||||
if (hours < 4) {
|
||||
if (minutes % 60 > 0) {
|
||||
segments.push(plural(minutes % 60, "minute"));
|
||||
}
|
||||
|
||||
if (minutes < 5) {
|
||||
if (seconds % 60 > 0) {
|
||||
segments.push(plural(seconds % 60, "second"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return segments.join(", ");
|
||||
}
|
||||
|
||||
/** this is producing local time! **/
|
||||
function formatDate(date: Date) {
|
||||
// return padNum(date.getDate()) + ". " + padNum(date.getMonth() + 1) + ". " + date.getFullYear();
|
||||
// instead of european format we'll just use ISO as that's pretty unambiguous
|
||||
|
||||
return formatDateISO(date);
|
||||
}
|
||||
|
||||
/** this is producing local time! **/
|
||||
function formatDateISO(date: Date) {
|
||||
return `${date.getFullYear()}-${padNum(date.getMonth() + 1)}-${padNum(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date, userSuppliedFormat?: string): string {
|
||||
if (userSuppliedFormat?.trim()) {
|
||||
return dayjs(date).format(userSuppliedFormat);
|
||||
} else {
|
||||
return `${formatDate(date)} ${formatTime(date)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function localNowDateTime() {
|
||||
return dayjs().format("YYYY-MM-DD HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
function now() {
|
||||
return formatTimeWithSeconds(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the client is currently running under Electron, or `false` if running in a web browser.
|
||||
*/
|
||||
function isElectron() {
|
||||
return !!(window && window.process && window.process.type);
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.indexOf("Mac") > -1;
|
||||
}
|
||||
|
||||
export const hasTouchBar = (isMac() && isElectron());
|
||||
|
||||
function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery.ContextMenuEvent | JQuery.TriggeredEvent | React.PointerEvent<HTMLCanvasElement> | JQueryEventObject) {
|
||||
return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey);
|
||||
}
|
||||
|
||||
function assertArguments<T>(...args: T[]) {
|
||||
for (const i in args) {
|
||||
if (!args[i]) {
|
||||
console.trace(`Argument idx#${i} should not be falsy: ${args[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entityMap: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
"`": "`",
|
||||
"=": "="
|
||||
};
|
||||
|
||||
function escapeHtml(str: string) {
|
||||
return str.replace(/[&<>"'`=\/]/g, (s) => entityMap[s]);
|
||||
}
|
||||
|
||||
export function escapeQuotes(value: string) {
|
||||
return value.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function formatSize(size: number) {
|
||||
size = Math.max(Math.round(size / 1024), 1);
|
||||
|
||||
if (size < 1024) {
|
||||
return `${size} KiB`;
|
||||
} else {
|
||||
return `${Math.round(size / 102.4) / 10} MiB`;
|
||||
}
|
||||
}
|
||||
|
||||
function toObject<T, R>(array: T[], fn: (arg0: T) => [key: string, value: R]) {
|
||||
const obj: Record<string, R> = {};
|
||||
|
||||
for (const item of array) {
|
||||
const [key, value] = fn(item);
|
||||
|
||||
obj[key] = value;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
function randomString(len: number) {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return (
|
||||
window.glob?.device === "mobile" ||
|
||||
// window.glob.device is not available in setup
|
||||
(!window.glob?.device && /Mobi/.test(navigator.userAgent))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the client device is an Apple iOS one (iPad, iPhone, iPod).
|
||||
* Does not check if the user requested the mobile or desktop layout, use {@link isMobile} for that.
|
||||
*
|
||||
* @returns `true` if running under iOS.
|
||||
*/
|
||||
export function isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function isDesktop() {
|
||||
return (
|
||||
window.glob?.device === "desktop" ||
|
||||
// window.glob.device is not available in setup
|
||||
(!window.glob?.device && !/Mobi/.test(navigator.userAgent))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* the cookie code below works for simple use cases only - ASCII only
|
||||
* not setting a path so that cookies do not leak into other websites if multiplexed with reverse proxy
|
||||
*/
|
||||
function setCookie(name: string, value: string) {
|
||||
const date = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
|
||||
const expires = `; expires=${date.toUTCString()}`;
|
||||
|
||||
document.cookie = `${name}=${value || ""}${expires};`;
|
||||
}
|
||||
|
||||
function getNoteTypeClass(type: string) {
|
||||
return `type-${type}`;
|
||||
}
|
||||
|
||||
function getMimeTypeClass(mime: string) {
|
||||
if (!mime) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const semicolonIdx = mime.indexOf(";");
|
||||
|
||||
if (semicolonIdx !== -1) {
|
||||
// stripping everything following the semicolon
|
||||
mime = mime.substr(0, semicolonIdx);
|
||||
}
|
||||
|
||||
return `mime-${mime.toLowerCase().replace(/[\W_]+/g, "-")}`;
|
||||
}
|
||||
|
||||
function isHtmlEmpty(html: string) {
|
||||
if (!html) {
|
||||
return true;
|
||||
} else if (typeof html !== "string") {
|
||||
logError(`Got object of type '${typeof html}' where string was expected.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
html = html.toLowerCase();
|
||||
|
||||
return (
|
||||
!html.includes("<img") &&
|
||||
!html.includes("<section") &&
|
||||
// the line below will actually attempt to load images so better to check for images first
|
||||
$("<div>").html(html).text().trim().length === 0
|
||||
);
|
||||
}
|
||||
|
||||
async function clearBrowserCache() {
|
||||
if (isElectron()) {
|
||||
const win = dynamicRequire("@electron/remote").getCurrentWindow();
|
||||
await win.webContents.session.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
function copySelectionToClipboard() {
|
||||
const text = window?.getSelection()?.toString();
|
||||
if (text && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
function dynamicRequire(moduleName: string) {
|
||||
if (typeof __non_webpack_require__ !== "undefined") {
|
||||
return __non_webpack_require__(moduleName);
|
||||
} else {
|
||||
// explicitly pass as string and not as expression to suppress webpack warning
|
||||
// 'Critical dependency: the request of a dependency is an expression'
|
||||
return require(`${moduleName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function timeLimit<T>(promise: Promise<T>, limitMs: number, errorMessage?: string) {
|
||||
if (!promise || !promise.then) {
|
||||
// it's not actually a promise
|
||||
return promise;
|
||||
}
|
||||
|
||||
// better stack trace if created outside of promise
|
||||
const error = new Error(errorMessage || `Process exceeded time limit ${limitMs}`);
|
||||
|
||||
return new Promise<T>((res, rej) => {
|
||||
let resolved = false;
|
||||
|
||||
promise.then((result) => {
|
||||
resolved = true;
|
||||
|
||||
res(result);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
rej(error);
|
||||
}
|
||||
}, limitMs);
|
||||
});
|
||||
}
|
||||
|
||||
function initHelpDropdown($el: JQuery<HTMLElement>) {
|
||||
// stop inside clicks from closing the menu
|
||||
const $dropdownMenu = $el.find(".help-dropdown .dropdown-menu");
|
||||
$dropdownMenu.on("click", (e) => e.stopPropagation());
|
||||
|
||||
// previous propagation stop will also block help buttons from being opened, so we need to re-init for this element
|
||||
initHelpButtons($dropdownMenu);
|
||||
}
|
||||
|
||||
const wikiBaseUrl = "https://triliumnext.github.io/Docs/Wiki/";
|
||||
|
||||
function openHelp($button: JQuery<HTMLElement>) {
|
||||
if ($button.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const helpPage = $button.attr("data-help-page");
|
||||
|
||||
if (helpPage) {
|
||||
const url = wikiBaseUrl + helpPage;
|
||||
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async function openInAppHelp($button: JQuery<HTMLElement>) {
|
||||
if ($button.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inAppHelpPage = $button.attr("data-in-app-help");
|
||||
if (inAppHelpPage) {
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
const subContexts = activeContext.getSubContexts();
|
||||
const targetNote = `_help_${inAppHelpPage}`;
|
||||
const helpSubcontext = subContexts.find((s) => s.viewScope?.viewMode === "contextual-help");
|
||||
const viewScope: ViewScope = {
|
||||
viewMode: "contextual-help",
|
||||
};
|
||||
if (!helpSubcontext) {
|
||||
// The help is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNote,
|
||||
hoistedNoteId: "_help",
|
||||
viewScope
|
||||
})
|
||||
} else {
|
||||
// There is already a help window open, make sure it opens on the right note.
|
||||
helpSubcontext.setNote(targetNote, { viewScope });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
|
||||
// for some reason, the .on(event, listener, handler) does not work here (e.g. Options -> Sync -> Help button)
|
||||
// so we do it manually
|
||||
$el.on("click", (e) => {
|
||||
openHelp($(e.target).closest("[data-help-page]"));
|
||||
openInAppHelp($(e.target).closest("[data-in-app-help]"));
|
||||
});
|
||||
}
|
||||
|
||||
function filterAttributeName(name: string) {
|
||||
return name.replace(/[^\p{L}\p{N}_:]/gu, "");
|
||||
}
|
||||
|
||||
const ATTR_NAME_MATCHER = new RegExp("^[\\p{L}\\p{N}_:]+$", "u");
|
||||
|
||||
function isValidAttributeName(name: string) {
|
||||
return ATTR_NAME_MATCHER.test(name);
|
||||
}
|
||||
|
||||
function sleep(time_ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, time_ms);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string) {
|
||||
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
function areObjectsEqual(...args: unknown[]) {
|
||||
let i;
|
||||
let l;
|
||||
let leftChain: Object[];
|
||||
let rightChain: Object[];
|
||||
|
||||
function compare2Objects(x: unknown, y: unknown) {
|
||||
let p;
|
||||
|
||||
// remember that NaN === NaN returns false
|
||||
// and isNaN(undefined) returns true
|
||||
if (typeof x === "number" && typeof y === "number" && isNaN(x) && isNaN(y)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare primitives and functions.
|
||||
// Check if both arguments link to the same object.
|
||||
// Especially useful on the step where we compare prototypes
|
||||
if (x === y) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Works in case when functions are created in constructor.
|
||||
// Comparing dates is a common scenario. Another built-ins?
|
||||
// We can even handle functions passed across iframes
|
||||
if (
|
||||
(typeof x === "function" && typeof y === "function") ||
|
||||
(x instanceof Date && y instanceof Date) ||
|
||||
(x instanceof RegExp && y instanceof RegExp) ||
|
||||
(x instanceof String && y instanceof String) ||
|
||||
(x instanceof Number && y instanceof Number)
|
||||
) {
|
||||
return x.toString() === y.toString();
|
||||
}
|
||||
|
||||
// At last, checking prototypes as good as we can
|
||||
if (!(x instanceof Object && y instanceof Object)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (x.isPrototypeOf(y) || y.isPrototypeOf(x)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (x.constructor !== y.constructor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((x as any).prototype !== (y as any).prototype) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for infinitive linking loops
|
||||
if (leftChain.indexOf(x) > -1 || rightChain.indexOf(y) > -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Quick checking of one object being a subset of another.
|
||||
// todo: cache the structure of arguments[0] for performance
|
||||
for (p in y) {
|
||||
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
|
||||
return false;
|
||||
} else if (typeof (y as any)[p] !== typeof (x as any)[p]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (p in x) {
|
||||
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
|
||||
return false;
|
||||
} else if (typeof (y as any)[p] !== typeof (x as any)[p]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (typeof (x as any)[p]) {
|
||||
case "object":
|
||||
case "function":
|
||||
leftChain.push(x);
|
||||
rightChain.push(y);
|
||||
|
||||
if (!compare2Objects((x as any)[p], (y as any)[p])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
leftChain.pop();
|
||||
rightChain.pop();
|
||||
break;
|
||||
|
||||
default:
|
||||
if ((x as any)[p] !== (y as any)[p]) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (arguments.length < 1) {
|
||||
return true; //Die silently? Don't know how to handle such case, please help...
|
||||
// throw "Need two or more arguments to compare";
|
||||
}
|
||||
|
||||
for (i = 1, l = arguments.length; i < l; i++) {
|
||||
leftChain = []; //Todo: this can be cached
|
||||
rightChain = [];
|
||||
|
||||
if (!compare2Objects(arguments[0], arguments[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyHtmlToClipboard(content: string) {
|
||||
function listener(e: ClipboardEvent) {
|
||||
if (e.clipboardData) {
|
||||
e.clipboardData.setData("text/html", content);
|
||||
e.clipboardData.setData("text/plain", content);
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
document.addEventListener("copy", listener);
|
||||
document.execCommand("copy");
|
||||
document.removeEventListener("copy", listener);
|
||||
}
|
||||
|
||||
// TODO: Set to FNote once the file is ported.
|
||||
function createImageSrcUrl(note: { noteId: string; title: string }) {
|
||||
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string representation of an SVG, triggers a download of the file on the client device.
|
||||
*
|
||||
* @param nameWithoutExtension the name of the file. The .svg suffix is automatically added to it.
|
||||
* @param svgContent the content of the SVG file download.
|
||||
*/
|
||||
function downloadSvg(nameWithoutExtension: string, svgContent: string) {
|
||||
const filename = `${nameWithoutExtension}.svg`;
|
||||
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
|
||||
triggerDownload(filename, dataUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the given data URL on the client device, with a custom file name.
|
||||
*
|
||||
* @param fileName the name to give the downloaded file.
|
||||
* @param dataUrl the data URI to download.
|
||||
*/
|
||||
function triggerDownload(fileName: string, dataUrl: string) {
|
||||
const element = document.createElement("a");
|
||||
element.setAttribute("href", dataUrl);
|
||||
element.setAttribute("download", fileName);
|
||||
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
|
||||
element.click();
|
||||
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string representation of an SVG, renders the SVG to PNG and triggers a download of the file on the client device.
|
||||
*
|
||||
* Note that the SVG must specify its width and height as attributes in order for it to be rendered.
|
||||
*
|
||||
* @param nameWithoutExtension the name of the file. The .png suffix is automatically added to it.
|
||||
* @param svgContent the content of the SVG file download.
|
||||
* @returns a promise which resolves if the operation was successful, or rejects if it failed (permissions issue or some other issue).
|
||||
*/
|
||||
function downloadSvgAsPng(nameWithoutExtension: string, svgContent: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// First, we need to determine the width and the height from the input SVG.
|
||||
const result = getSizeFromSvg(svgContent);
|
||||
if (!result) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert the image to a blob.
|
||||
const { width, height } = result;
|
||||
|
||||
// Create an image element and load the SVG.
|
||||
const imageEl = new Image();
|
||||
imageEl.width = width;
|
||||
imageEl.height = height;
|
||||
imageEl.crossOrigin = "anonymous";
|
||||
imageEl.onload = () => {
|
||||
try {
|
||||
// Draw the image with a canvas.
|
||||
const canvasEl = document.createElement("canvas");
|
||||
canvasEl.width = imageEl.width;
|
||||
canvasEl.height = imageEl.height;
|
||||
document.body.appendChild(canvasEl);
|
||||
|
||||
const ctx = canvasEl.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject();
|
||||
}
|
||||
|
||||
ctx?.drawImage(imageEl, 0, 0);
|
||||
|
||||
const imgUri = canvasEl.toDataURL("image/png")
|
||||
triggerDownload(`${nameWithoutExtension}.png`, imgUri);
|
||||
document.body.removeChild(canvasEl);
|
||||
resolve();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
reject();
|
||||
}
|
||||
};
|
||||
imageEl.onerror = (e) => reject(e);
|
||||
imageEl.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgContent)}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function getSizeFromSvg(svgContent: string) {
|
||||
const svgDocument = (new DOMParser()).parseFromString(svgContent, SVG_MIME);
|
||||
|
||||
// Try to use width & height attributes if available.
|
||||
let width = svgDocument.documentElement?.getAttribute("width");
|
||||
let height = svgDocument.documentElement?.getAttribute("height");
|
||||
|
||||
// If not, use the viewbox.
|
||||
if (!width || !height) {
|
||||
const viewBox = svgDocument.documentElement?.getAttribute("viewBox");
|
||||
if (viewBox) {
|
||||
const viewBoxParts = viewBox.split(" ");
|
||||
width = viewBoxParts[2];
|
||||
height = viewBoxParts[3];
|
||||
}
|
||||
}
|
||||
|
||||
if (width && height) {
|
||||
return {
|
||||
width: parseFloat(width),
|
||||
height: parseFloat(height)
|
||||
}
|
||||
} else {
|
||||
console.warn("SVG export error", svgDocument.documentElement);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings.
|
||||
* Returns:
|
||||
* 1 if v1 is greater than v2
|
||||
* 0 if v1 is equal to v2
|
||||
* -1 if v1 is less than v2
|
||||
*
|
||||
* @param v1 First version string
|
||||
* @param v2 Second version string
|
||||
* @returns
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
// Remove 'v' prefix and everything after dash if present
|
||||
v1 = v1.replace(/^v/, "").split("-")[0];
|
||||
v2 = v2.replace(/^v/, "").split("-")[0];
|
||||
|
||||
const v1parts = v1.split(".").map(Number);
|
||||
const v2parts = v2.split(".").map(Number);
|
||||
|
||||
// Pad shorter version with zeros
|
||||
while (v1parts.length < 3) v1parts.push(0);
|
||||
while (v2parts.length < 3) v2parts.push(0);
|
||||
|
||||
// Compare major version
|
||||
if (v1parts[0] !== v2parts[0]) {
|
||||
return v1parts[0] > v2parts[0] ? 1 : -1;
|
||||
}
|
||||
|
||||
// Compare minor version
|
||||
if (v1parts[1] !== v2parts[1]) {
|
||||
return v1parts[1] > v2parts[1] ? 1 : -1;
|
||||
}
|
||||
|
||||
// Compare patch version
|
||||
if (v1parts[2] !== v2parts[2]) {
|
||||
return v1parts[2] > v2parts[2] ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings and returns `true` if the latest version is greater than the current version.
|
||||
*/
|
||||
function isUpdateAvailable(latestVersion: string | null | undefined, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
return compareVersions(latestVersion, currentVersion) > 0;
|
||||
}
|
||||
|
||||
function isLaunchBarConfig(noteId: string) {
|
||||
return ["_lbRoot", "_lbAvailableLaunchers", "_lbVisibleLaunchers", "_lbMobileRoot", "_lbMobileAvailableLaunchers", "_lbMobileVisibleLaunchers"].includes(noteId);
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
reloadTray,
|
||||
parseDate,
|
||||
getMonthsInDateRange,
|
||||
formatDateISO,
|
||||
formatDateTime,
|
||||
formatTimeInterval,
|
||||
formatSize,
|
||||
localNowDateTime,
|
||||
now,
|
||||
isElectron,
|
||||
isMac,
|
||||
isCtrlKey,
|
||||
assertArguments,
|
||||
escapeHtml,
|
||||
toObject,
|
||||
randomString,
|
||||
isMobile,
|
||||
isDesktop,
|
||||
setCookie,
|
||||
getNoteTypeClass,
|
||||
getMimeTypeClass,
|
||||
isHtmlEmpty,
|
||||
clearBrowserCache,
|
||||
copySelectionToClipboard,
|
||||
dynamicRequire,
|
||||
timeLimit,
|
||||
initHelpDropdown,
|
||||
initHelpButtons,
|
||||
openHelp,
|
||||
filterAttributeName,
|
||||
isValidAttributeName,
|
||||
sleep,
|
||||
escapeRegExp,
|
||||
areObjectsEqual,
|
||||
copyHtmlToClipboard,
|
||||
createImageSrcUrl,
|
||||
downloadSvg,
|
||||
downloadSvgAsPng,
|
||||
compareVersions,
|
||||
isUpdateAvailable,
|
||||
isLaunchBarConfig
|
||||
};
|
||||
@ -1,315 +0,0 @@
|
||||
import utils from "./utils.js";
|
||||
import toastService from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import options from "./options.js";
|
||||
import frocaUpdater from "./froca_updater.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
|
||||
type MessageHandler = (message: any) => void;
|
||||
const messageHandlers: MessageHandler[] = [];
|
||||
|
||||
let ws: WebSocket;
|
||||
let lastAcceptedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
|
||||
let lastAcceptedEntityChangeSyncId = window.glob.maxEntityChangeSyncIdAtLoad;
|
||||
let lastProcessedEntityChangeId = window.glob.maxEntityChangeIdAtLoad;
|
||||
let lastPingTs: number;
|
||||
let frontendUpdateDataQueue: EntityChange[] = [];
|
||||
|
||||
export function logError(message: string) {
|
||||
console.error(utils.now(), message); // needs to be separate from .trace()
|
||||
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "log-error",
|
||||
error: message,
|
||||
stack: new Error().stack
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logInfo(message: string) {
|
||||
console.log(utils.now(), message);
|
||||
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "log-info",
|
||||
info: message
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.logError = logError;
|
||||
window.logInfo = logInfo;
|
||||
|
||||
function subscribeToMessages(messageHandler: MessageHandler) {
|
||||
messageHandlers.push(messageHandler);
|
||||
}
|
||||
|
||||
// used to serialize frontend update operations
|
||||
let consumeQueuePromise: Promise<void> | null = null;
|
||||
|
||||
// to make sure each change event is processed only once. Not clear if this is still necessary
|
||||
const processedEntityChangeIds = new Set();
|
||||
|
||||
function logRows(entityChanges: EntityChange[]) {
|
||||
const filteredRows = entityChanges.filter((row) => !processedEntityChangeIds.has(row.id) && (row.entityName !== "options" || row.entityId !== "openNoteContexts"));
|
||||
|
||||
if (filteredRows.length > 0) {
|
||||
console.debug(utils.now(), "Frontend update data: ", filteredRows);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeFrontendUpdate(entityChanges: EntityChange[]) {
|
||||
lastPingTs = Date.now();
|
||||
|
||||
if (entityChanges.length > 0) {
|
||||
logRows(entityChanges);
|
||||
|
||||
frontendUpdateDataQueue.push(...entityChanges);
|
||||
|
||||
// we set lastAcceptedEntityChangeId even before frontend update processing and send ping so that backend can start sending more updates
|
||||
|
||||
for (const entityChange of entityChanges) {
|
||||
if (!entityChange.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
lastAcceptedEntityChangeId = Math.max(lastAcceptedEntityChangeId, entityChange.id);
|
||||
|
||||
if (entityChange.isSynced) {
|
||||
lastAcceptedEntityChangeSyncId = Math.max(lastAcceptedEntityChangeSyncId, entityChange.id);
|
||||
}
|
||||
}
|
||||
|
||||
sendPing();
|
||||
|
||||
// first wait for all the preceding consumers to finish
|
||||
while (consumeQueuePromise) {
|
||||
await consumeQueuePromise;
|
||||
}
|
||||
|
||||
try {
|
||||
// it's my turn, so start it up
|
||||
consumeQueuePromise = consumeFrontendUpdateData();
|
||||
|
||||
await consumeQueuePromise;
|
||||
} finally {
|
||||
// finish and set to null to signal somebody else can pick it up
|
||||
consumeQueuePromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessage(event: MessageEvent<any>) {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
for (const messageHandler of messageHandlers) {
|
||||
messageHandler(message);
|
||||
}
|
||||
|
||||
if (message.type === "ping") {
|
||||
lastPingTs = Date.now();
|
||||
} else if (message.type === "reload-frontend") {
|
||||
utils.reloadFrontendApp("received request from backend to reload frontend");
|
||||
} else if (message.type === "frontend-update") {
|
||||
await executeFrontendUpdate(message.data.entityChanges);
|
||||
} else if (message.type === "sync-hash-check-failed") {
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
} else if (message.type === "consistency-checks-failed") {
|
||||
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
} else if (message.type === "api-log-messages") {
|
||||
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
|
||||
} else if (message.type === "toast") {
|
||||
toastService.showMessage(message.message);
|
||||
} else if (message.type === "llm-stream") {
|
||||
// ENHANCED LOGGING FOR DEBUGGING
|
||||
console.log(`[WS-CLIENT] >>> RECEIVED LLM STREAM MESSAGE <<<`);
|
||||
console.log(`[WS-CLIENT] Message details: sessionId=${message.sessionId}, hasContent=${!!message.content}, contentLength=${message.content ? message.content.length : 0}, hasThinking=${!!message.thinking}, hasToolExecution=${!!message.toolExecution}, isDone=${!!message.done}`);
|
||||
|
||||
if (message.content) {
|
||||
console.log(`[WS-CLIENT] CONTENT PREVIEW: "${message.content.substring(0, 50)}..."`);
|
||||
}
|
||||
|
||||
// Create the event with detailed logging
|
||||
console.log(`[WS-CLIENT] Creating CustomEvent 'llm-stream-message'`);
|
||||
const llmStreamEvent = new CustomEvent('llm-stream-message', { detail: message });
|
||||
|
||||
// Dispatch to multiple targets to ensure delivery
|
||||
try {
|
||||
console.log(`[WS-CLIENT] Dispatching event to window`);
|
||||
window.dispatchEvent(llmStreamEvent);
|
||||
console.log(`[WS-CLIENT] Event dispatched to window`);
|
||||
|
||||
// Also try document for completeness
|
||||
console.log(`[WS-CLIENT] Dispatching event to document`);
|
||||
document.dispatchEvent(new CustomEvent('llm-stream-message', { detail: message }));
|
||||
console.log(`[WS-CLIENT] Event dispatched to document`);
|
||||
} catch (err) {
|
||||
console.error(`[WS-CLIENT] Error dispatching event:`, err);
|
||||
}
|
||||
|
||||
// Debug current listeners (though we can't directly check for specific event listeners)
|
||||
console.log(`[WS-CLIENT] Active event listeners should receive this message now`);
|
||||
|
||||
// Detailed logging based on message type
|
||||
if (message.content) {
|
||||
console.log(`[WS-CLIENT] Content message: ${message.content.length} chars`);
|
||||
} else if (message.thinking) {
|
||||
console.log(`[WS-CLIENT] Thinking update: "${message.thinking}"`);
|
||||
} else if (message.toolExecution) {
|
||||
console.log(`[WS-CLIENT] Tool execution: action=${message.toolExecution.action}, tool=${message.toolExecution.tool || 'unknown'}`);
|
||||
if (message.toolExecution.result) {
|
||||
console.log(`[WS-CLIENT] Tool result preview: "${String(message.toolExecution.result).substring(0, 50)}..."`);
|
||||
}
|
||||
} else if (message.done) {
|
||||
console.log(`[WS-CLIENT] Completion signal received`);
|
||||
}
|
||||
} else if (message.type === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
|
||||
|
||||
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
|
||||
}
|
||||
}
|
||||
|
||||
let entityChangeIdReachedListeners: {
|
||||
desiredEntityChangeId: number;
|
||||
resolvePromise: () => void;
|
||||
start: number;
|
||||
}[] = [];
|
||||
|
||||
function waitForEntityChangeId(desiredEntityChangeId: number) {
|
||||
if (desiredEntityChangeId <= lastProcessedEntityChangeId) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
console.debug(`Waiting for ${desiredEntityChangeId}, last processed is ${lastProcessedEntityChangeId}, last accepted ${lastAcceptedEntityChangeId}`);
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
entityChangeIdReachedListeners.push({
|
||||
desiredEntityChangeId: desiredEntityChangeId,
|
||||
resolvePromise: res,
|
||||
start: Date.now()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForMaxKnownEntityChangeId() {
|
||||
return waitForEntityChangeId(server.getMaxKnownEntityChangeId());
|
||||
}
|
||||
|
||||
function checkEntityChangeIdListeners() {
|
||||
entityChangeIdReachedListeners.filter((l) => l.desiredEntityChangeId <= lastProcessedEntityChangeId).forEach((l) => l.resolvePromise());
|
||||
|
||||
entityChangeIdReachedListeners = entityChangeIdReachedListeners.filter((l) => l.desiredEntityChangeId > lastProcessedEntityChangeId);
|
||||
|
||||
entityChangeIdReachedListeners
|
||||
.filter((l) => Date.now() > l.start - 60000)
|
||||
.forEach((l) =>
|
||||
console.log(
|
||||
`Waiting for entityChangeId ${l.desiredEntityChangeId} while last processed is ${lastProcessedEntityChangeId} (last accepted ${lastAcceptedEntityChangeId}) for ${Math.floor((Date.now() - l.start) / 1000)}s`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function consumeFrontendUpdateData() {
|
||||
if (frontendUpdateDataQueue.length > 0) {
|
||||
const allEntityChanges = frontendUpdateDataQueue;
|
||||
frontendUpdateDataQueue = [];
|
||||
|
||||
const nonProcessedEntityChanges = allEntityChanges.filter((ec) => !processedEntityChangeIds.has(ec.id));
|
||||
|
||||
try {
|
||||
await utils.timeLimit(frocaUpdater.processEntityChanges(nonProcessedEntityChanges), 30000);
|
||||
} catch (e: any) {
|
||||
logError(`Encountered error ${e.message}: ${e.stack}, reloading frontend.`);
|
||||
|
||||
if (!glob.isDev && !options.is("debugModeEnabled")) {
|
||||
// if there's an error in updating the frontend, then the easy option to recover is to reload the frontend completely
|
||||
|
||||
utils.reloadFrontendApp();
|
||||
} else {
|
||||
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
|
||||
|
||||
toastService.showError(t("ws.encountered-error", { message: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const entityChange of nonProcessedEntityChanges) {
|
||||
processedEntityChangeIds.add(entityChange.id);
|
||||
|
||||
if (entityChange.id) {
|
||||
lastProcessedEntityChangeId = Math.max(lastProcessedEntityChangeId, entityChange.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkEntityChangeIdListeners();
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const loc = window.location;
|
||||
const webSocketUri = `${loc.protocol === "https:" ? "wss:" : "ws:"}//${loc.host}${loc.pathname}`;
|
||||
|
||||
// use wss for secure messaging
|
||||
const ws = new WebSocket(webSocketUri);
|
||||
ws.onopen = () => console.debug(utils.now(), `Connected to server ${webSocketUri} with WebSocket`);
|
||||
ws.onmessage = handleMessage;
|
||||
// we're not handling ws.onclose here because reconnection is done in sendPing()
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function sendPing() {
|
||||
if (Date.now() - lastPingTs > 30000) {
|
||||
console.log(
|
||||
utils.now(),
|
||||
"Lost websocket connection to the backend. If you keep having this issue repeatedly, you might want to check your reverse proxy (nginx, apache) configuration and allow/unblock WebSocket."
|
||||
);
|
||||
}
|
||||
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "ping",
|
||||
lastEntityChangeId: lastAcceptedEntityChangeId
|
||||
})
|
||||
);
|
||||
} else if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
|
||||
console.log(utils.now(), "WS closed or closing, trying to reconnect");
|
||||
|
||||
ws = connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
ws = connectWebSocket();
|
||||
|
||||
lastPingTs = Date.now();
|
||||
|
||||
setInterval(sendPing, 1000);
|
||||
}, 0);
|
||||
|
||||
export function throwError(message: string) {
|
||||
logError(message);
|
||||
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export default {
|
||||
logError,
|
||||
subscribeToMessages,
|
||||
waitForMaxKnownEntityChangeId,
|
||||
getMaxKnownEntityChangeSyncId: () => lastAcceptedEntityChangeSyncId
|
||||
};
|
||||
@ -1,7 +0,0 @@
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
import "./stylesheets/auth.css";
|
||||
|
||||
// @TriliumNextTODO: is this even needed anymore?
|
||||
// @ts-ignore - module = undefined
|
||||
// Required for correct loading of scripts in Electron
|
||||
if (typeof module === 'object') {window.module = module; module = undefined;}
|
||||
@ -1,130 +0,0 @@
|
||||
import "jquery";
|
||||
import "jquery-hotkeys";
|
||||
import utils from "./services/utils.js";
|
||||
import ko from "knockout";
|
||||
import "./stylesheets/bootstrap.scss";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
|
||||
|
||||
class SetupModel {
|
||||
syncInProgress: boolean;
|
||||
step: ko.Observable<string>;
|
||||
setupType: ko.Observable<string>;
|
||||
setupNewDocument: ko.Observable<boolean>;
|
||||
setupSyncFromDesktop: ko.Observable<boolean>;
|
||||
setupSyncFromServer: ko.Observable<boolean>;
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
|
||||
this.setupType = ko.observable("");
|
||||
this.setupNewDocument = ko.observable(false);
|
||||
this.setupSyncFromDesktop = ko.observable(false);
|
||||
this.setupSyncFromServer = ko.observable(false);
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
}
|
||||
|
||||
// this is called in setup.ejs
|
||||
setupTypeSelected() {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
$.post("api/setup/new-document").then(() => {
|
||||
window.location.replace("./setup");
|
||||
});
|
||||
} else {
|
||||
this.step(this.setupType());
|
||||
}
|
||||
}
|
||||
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
}
|
||||
|
||||
async finish() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
const syncProxy = this.syncProxy();
|
||||
const password = this.password();
|
||||
|
||||
if (!syncServerHost) {
|
||||
showAlert("Trilium server address can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showAlert("Password can't be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost: syncServerHost,
|
||||
syncProxy: syncProxy,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
this.step("sync-in-progress");
|
||||
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
|
||||
hideAlert();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
const { outstandingPullCount, initialized } = await $.get("api/sync/stats");
|
||||
|
||||
if (initialized) {
|
||||
if (utils.isElectron()) {
|
||||
const remote = utils.dynamicRequire("@electron/remote");
|
||||
remote.app.relaunch();
|
||||
remote.app.exit(0);
|
||||
} else {
|
||||
utils.reloadFrontendApp();
|
||||
}
|
||||
} else {
|
||||
$("#outstanding-syncs").html(outstandingPullCount);
|
||||
}
|
||||
}
|
||||
|
||||
function showAlert(message: string) {
|
||||
$("#alert").text(message);
|
||||
$("#alert").show();
|
||||
}
|
||||
|
||||
function hideAlert() {
|
||||
$("#alert").hide();
|
||||
}
|
||||
|
||||
function getSyncInProgress() {
|
||||
const el = document.getElementById("syncInProgress");
|
||||
if (!el || !(el instanceof HTMLMetaElement)) return false;
|
||||
return !!parseInt(el.content);
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
ko.applyBindings(new SetupModel(getSyncInProgress()), document.getElementById("setup-dialog"));
|
||||
$("#setup-dialog").show();
|
||||
});
|
||||
@ -1,67 +0,0 @@
|
||||
import "normalize.css";
|
||||
import "boxicons/css/boxicons.min.css";
|
||||
import "@triliumnext/ckeditor5/content.css";
|
||||
import "@triliumnext/share-theme/styles/index.css";
|
||||
import "@triliumnext/share-theme/scripts/index.js";
|
||||
|
||||
async function ensureJQuery() {
|
||||
const $ = (await import("jquery")).default;
|
||||
(window as any).$ = $;
|
||||
}
|
||||
|
||||
async function applyMath() {
|
||||
const anyMathBlock = document.querySelector("#content .math-tex");
|
||||
if (!anyMathBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderMathInElement = (await import("./services/math.js")).renderMathInElement;
|
||||
renderMathInElement(document.getElementById("content"));
|
||||
}
|
||||
|
||||
async function formatCodeBlocks() {
|
||||
const anyCodeBlock = document.querySelector("#content pre");
|
||||
if (!anyCodeBlock) {
|
||||
return;
|
||||
}
|
||||
await ensureJQuery();
|
||||
const { formatCodeBlocks } = await import("./services/syntax_highlight.js");
|
||||
await formatCodeBlocks($("#content"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch note with given ID from backend
|
||||
*
|
||||
* @param noteId of the given note to be fetched. If false, fetches current note.
|
||||
*/
|
||||
async function fetchNote(noteId: string | null = null) {
|
||||
if (!noteId) {
|
||||
noteId = document.body.getAttribute("data-note-id");
|
||||
}
|
||||
|
||||
const resp = await fetch(`api/notes/${noteId}`);
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
() => {
|
||||
formatCodeBlocks();
|
||||
applyMath();
|
||||
|
||||
const toggleMenuButton = document.getElementById("toggleMenuButton");
|
||||
const layout = document.getElementById("layout");
|
||||
|
||||
if (toggleMenuButton && layout) {
|
||||
toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu"));
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
// workaround to prevent webpack from removing "fetchNote" as dead code:
|
||||
// add fetchNote as property to the window object
|
||||
Object.defineProperty(window, "fetchNote", {
|
||||
value: fetchNote
|
||||
});
|
||||
@ -1,3 +0,0 @@
|
||||
.set-password .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
/* Import all of Bootstrap's CSS */
|
||||
@use "bootstrap/scss/bootstrap";
|
||||
@ -1,173 +0,0 @@
|
||||
.calendar-dropdown-widget *,
|
||||
.calendar-dropdown-widget *:before,
|
||||
.calendar-dropdown-widget *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-btn {
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-appearance: button;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: x-large;
|
||||
line-height: normal;
|
||||
min-width: 27px;
|
||||
outline: none;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 0.5rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header>div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header input[type="number"] {
|
||||
appearance: textfield !important;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header input[type="number"]::-webkit-outer-spin-button,
|
||||
.calendar-dropdown-widget .calendar-header input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header input,
|
||||
.calendar-dropdown-widget .calendar-header .dropdown-toggle {
|
||||
appearance: none;
|
||||
text-align: center;
|
||||
border: 0;
|
||||
border-left: unset;
|
||||
background-color: var(--menu-background-color);
|
||||
font-weight: bold;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-header .dropdown-toggle::after {
|
||||
border: unset;
|
||||
/* Disable the dropdown arrow */
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-week {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-week span {
|
||||
flex-direction: column;
|
||||
flex: 0 0 12.5%;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
max-width: 12.5%;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-week-number {
|
||||
color: var(--muted-text-color) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-week-number::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--main-border-color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-week-number-disabled {
|
||||
align-items: center;
|
||||
color: var(--main-text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 12.5%;
|
||||
max-width: 12.5%;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date {
|
||||
align-items: center;
|
||||
color: var(--main-text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 12.5%;
|
||||
max-width: 12.5%;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date:hover {
|
||||
color: var(--hover-item-text-color);
|
||||
background-color: var(--hover-item-background-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date-active {
|
||||
background-color: var(--active-item-background-color);
|
||||
color: var(--active-item-text-color);
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date-today {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date-exists {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date:not(.calendar-date-active) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date-prev-month,
|
||||
.calendar-dropdown-widget .calendar-date-next-month {
|
||||
color: var(--muted-text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.calendar-dropdown-widget .calendar-date-prev-month:hover,
|
||||
.calendar-dropdown-widget .calendar-date-next-month:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--hover-item-background-color);
|
||||
color: var(--hover-item-text-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@ -1,450 +0,0 @@
|
||||
/* LLM Chat Panel Styles */
|
||||
.note-context-chat {
|
||||
background-color: var(--main-background-color);
|
||||
}
|
||||
|
||||
/* Message Styling */
|
||||
.chat-message {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background-color: var(--input-background-color);
|
||||
color: var(--cmd-button-icon-color);
|
||||
}
|
||||
|
||||
.assistant-avatar {
|
||||
background-color: var(--subtle-border-color, var(--main-border-color));
|
||||
color: var(--hover-item-text-color);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: calc(100% - 50px);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.user-content {
|
||||
border-radius: 0.5rem 0.5rem 0 0.5rem !important;
|
||||
background-color: var(--input-background-color) !important;
|
||||
}
|
||||
|
||||
.assistant-content {
|
||||
border-radius: 0.5rem 0.5rem 0.5rem 0 !important;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
}
|
||||
|
||||
/* Tool Execution Styling */
|
||||
.tool-execution-info {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
background-color: var(--main-background-color);
|
||||
/* Add a subtle transition effect */
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.tool-execution-status {
|
||||
background-color: var(--accented-background-color, rgba(0, 0, 0, 0.03)) !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0.5rem !important;
|
||||
max-height: 250px !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-execution-status .d-flex {
|
||||
border-bottom: 1px solid var(--subtle-border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-step {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--main-background-color);
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-step:hover {
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.tool-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tool step specific styling */
|
||||
.tool-step.executing {
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
border-color: rgba(0, 123, 255, 0.2);
|
||||
}
|
||||
|
||||
.tool-step.result {
|
||||
background-color: rgba(40, 167, 69, 0.05);
|
||||
border-color: rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
.tool-step.error {
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border-color: rgba(220, 53, 69, 0.2);
|
||||
}
|
||||
|
||||
/* Tool result formatting */
|
||||
.tool-result pre {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 0.25rem;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.tool-result code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tool-args code {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
color: var(--muted-text-color);
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
max-height: 100px;
|
||||
}
|
||||
|
||||
/* Tool Execution in Chat Styling */
|
||||
.chat-tool-execution {
|
||||
padding: 0 0 0 36px; /* Aligned with message content, accounting for avatar width */
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tool-execution-container {
|
||||
background-color: var(--accented-background-color, rgba(245, 247, 250, 0.7));
|
||||
border: 1px solid var(--subtle-border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
max-width: calc(100% - 20px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-container.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-execution-header {
|
||||
background-color: var(--main-background-color);
|
||||
border-bottom: 1px solid var(--subtle-border-color);
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted-text-color);
|
||||
font-weight: 500;
|
||||
padding: 0.6rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-header:hover {
|
||||
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.tool-execution-toggle {
|
||||
color: var(--muted-text-color) !important;
|
||||
background: transparent !important;
|
||||
padding: 0.2rem 0.4rem !important;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-execution-toggle:hover {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.tool-execution-toggle i.bx-chevron-down {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-toggle i.bx-chevron-right {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-execution-chat-steps {
|
||||
padding: 0.5rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Make error text more visible */
|
||||
.text-danger {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
/* Sources Styling */
|
||||
.sources-container {
|
||||
background-color: var(--accented-background-color, var(--main-background-color));
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.source-item {
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--main-background-color);
|
||||
border-color: var(--subtle-border-color, var(--main-border-color)) !important;
|
||||
}
|
||||
|
||||
.source-item:hover {
|
||||
background-color: var(--link-hover-background, var(--hover-item-background-color));
|
||||
}
|
||||
|
||||
.source-link {
|
||||
color: var(--link-color, var(--hover-item-text-color));
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.source-link:hover {
|
||||
color: var(--link-hover-color, var(--hover-item-text-color));
|
||||
}
|
||||
|
||||
/* Input Area Styling */
|
||||
.note-context-chat-form {
|
||||
background-color: var(--main-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.context-option-container {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.note-context-chat-input {
|
||||
border-color: var(--subtle-border-color, var(--main-border-color));
|
||||
background-color: var(--input-background-color) !important;
|
||||
color: var(--input-text-color) !important;
|
||||
resize: none;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 50px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.note-context-chat-input:focus {
|
||||
border-color: var(--input-focus-outline-color, var(--main-border-color));
|
||||
box-shadow: 0 0 0 0.25rem var(--input-focus-outline-color, rgba(13, 110, 253, 0.25));
|
||||
}
|
||||
|
||||
.note-context-chat-send-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-self: flex-end;
|
||||
background-color: var(--cmd-button-background-color) !important;
|
||||
color: var(--cmd-button-text-color) !important;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
/* Thinking display styles */
|
||||
.llm-thinking-container {
|
||||
margin: 1rem 0;
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.thinking-bubble {
|
||||
background-color: var(--accented-background-color, var(--main-background-color));
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-bubble:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.thinking-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, var(--hover-item-background-color, rgba(0, 0, 0, 0.03)), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03));
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.thinking-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thinking-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: var(--link-color, var(--hover-item-text-color));
|
||||
border-radius: 50%;
|
||||
animation: thinkingPulse 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.thinking-dots span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
font-weight: 500;
|
||||
color: var(--link-color, var(--hover-item-text-color)) !important;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
color: var(--muted-text-color) !important;
|
||||
transition: transform 0.2s ease;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.thinking-toggle:hover {
|
||||
color: var(--main-text-color) !important;
|
||||
}
|
||||
|
||||
.thinking-toggle.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
animation: expandDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--main-text-color);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
background-color: var(--input-background-color);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--subtle-border-color, var(--main-border-color));
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-text:hover {
|
||||
border-color: var(--main-border-color);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes thinkingPulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.6;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expandDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.thinking-bubble {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
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
Loading…
Reference in New Issue